import os
import shutil
import threading
import time
import uuid
from datetime import datetime

from flask import (
    Blueprint,
    current_app,
    flash,
    jsonify,
    redirect,
    render_template,
    request,
    send_file,
    send_from_directory,
    url_for,
)
from flask_babel import gettext as _
from flask_login import current_user, login_required
from sqlalchemy import text
from sqlalchemy.exc import ProgrammingError
from werkzeug.utils import secure_filename

import app as app_module
from app import db, limiter
from app.config.analytics_defaults import get_analytics_config
from app.utils.safe_template_render import render_sandboxed_string
from app.models import (
    DonationInteraction,
    Invoice,
    Project,
    Quote,
    QuoteItem,
    Role,
    Settings,
    TimeEntry,
    User,
    UserClient,
)
from app.utils.backup import create_backup, get_backup_root_dir, restore_backup
from app.utils.db import safe_commit
from app.utils.error_handling import safe_file_remove, safe_log
from app.utils.installation import get_installation_config
from app.utils.invoice_numbering import sanitize_invoice_pattern, sanitize_invoice_prefix, validate_invoice_pattern
from app.utils.auth_method import auth_includes_ldap, auth_includes_oidc, normalize_auth_method
from app.utils.permissions import admin_or_permission_required
from app.utils.telemetry import get_telemetry_fingerprint, is_telemetry_enabled
from app.utils.timezone import get_available_timezones

admin_bp = Blueprint("admin", __name__)


def _ldap_admin_display():
    """Read-only LDAP config summary for admin settings (from env / app config)."""
    try:
        cfg = current_app.config
        ag = (cfg.get("LDAP_ADMIN_GROUP") or "").strip()
        rg = (cfg.get("LDAP_REQUIRED_GROUP") or "").strip()
        return {
            "enabled": bool(cfg.get("LDAP_ENABLED")),
            "host": cfg.get("LDAP_HOST") or "",
            "port": int(cfg.get("LDAP_PORT") or 389),
            "use_ssl": bool(cfg.get("LDAP_USE_SSL")),
            "use_tls": bool(cfg.get("LDAP_USE_TLS")),
            "base_dn": cfg.get("LDAP_BASE_DN") or "",
            "user_dn": cfg.get("LDAP_USER_DN") or "",
            "login_attr": cfg.get("LDAP_USER_LOGIN_ATTR") or "",
            "admin_group": ag or "—",
            "required_group": rg or "—",
        }
    except Exception:
        return {
            "enabled": False,
            "host": "",
            "port": 389,
            "use_ssl": False,
            "use_tls": False,
            "base_dn": "",
            "user_dn": "",
            "login_attr": "",
            "admin_group": "—",
            "required_group": "—",
        }


@admin_bp.context_processor
def _inject_ldap_admin_display():
    return {"ldap_settings": _ldap_admin_display()}


# In-memory restore progress tracking (simple, per-process)
RESTORE_PROGRESS = {}

# Allowed file extensions for logos
# Avoid SVG due to XSS risk unless sanitized server-side
ALLOWED_LOGO_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp"}


def _convert_json_template_to_html_css(template_json, page_size="A4", invoice=None, quote=None, settings=None):
    """
    Convert JSON template to HTML/CSS for preview purposes with full element type support.

    Args:
        template_json: Dictionary containing template definition
        page_size: Page size for CSS @page rule
        invoice: Optional invoice object for table data rendering
        quote: Optional quote object for table data rendering
        settings: Optional settings object for company information

    Returns:
        tuple: (html_string, css_string)
    """
    import html as html_escape
    import json as json_module

    from app.utils.pdf_template_schema import get_page_dimensions_points

    # Get page dimensions
    dims = get_page_dimensions_points(page_size)
    width_pt = dims["width"]
    height_pt = dims["height"]

    # Convert points to pixels at 96 DPI for browser (72 DPI * 96/72 = 1.333)
    # But for accuracy, use 1pt = 1.333px conversion for browser
    width_px = int(width_pt * 96 / 72)
    height_px = int(height_pt * 96 / 72)

    # Font mapping: ReportLab fonts to web fonts
    font_map = {
        "Helvetica": "Arial, Helvetica, sans-serif",
        "Helvetica-Bold": "Arial, Helvetica, sans-serif",
        "Helvetica-Oblique": "Arial, Helvetica, sans-serif",
        "Helvetica-BoldOblique": "Arial, Helvetica, sans-serif",
        "Times-Roman": "Times New Roman, Times, serif",
        "Times-Bold": "Times New Roman, Times, serif",
        "Times-Italic": "Times New Roman, Times, serif",
        "Times-BoldItalic": "Times New Roman, Times, serif",
        "Courier": "Courier New, Courier, monospace",
        "Courier-Bold": "Courier New, Courier, monospace",
        "Courier-Oblique": "Courier New, Courier, monospace",
        "Courier-BoldOblique": "Courier New, Courier, monospace",
    }

    # Get page margins from template
    page_config = template_json.get("page", {})
    margin_top = page_config.get("margin", {}).get("top", 20)
    margin_bottom = page_config.get("margin", {}).get("bottom", 20)
    margin_left = page_config.get("margin", {}).get("left", 20)
    margin_right = page_config.get("margin", {}).get("right", 20)

    # Build CSS with @page rule and comprehensive styles
    css = f"""@page {{
    size: {page_size};
    margin: {margin_top}mm {margin_right}mm {margin_bottom}mm {margin_left}mm;
}}

* {{
    box-sizing: border-box;
}}

body {{
    width: {width_px}px;
    height: {height_px}px;
    margin: 0;
    padding: 0;
    font-family: Arial, Helvetica, sans-serif;
    font-size: 10pt;
    background: white;
    overflow: hidden;
}}

.invoice-wrapper,
.quote-wrapper {{
    width: {width_px}px;
    height: {height_px}px;
    position: relative;
    background: white;
    margin: 0;
    padding: 0;
}}

.element {{
    position: absolute;
    box-sizing: border-box;
}}

.text-element {{
    white-space: pre-wrap;
    word-wrap: break-word;
    overflow-wrap: break-word;
    margin: 0;
    padding: 0;
}}

.image-element {{
    object-fit: contain;
    display: block;
}}

.rectangle-element {{
    box-sizing: border-box;
}}

.circle-element {{
    border-radius: 50%;
    box-sizing: border-box;
}}

.line-element {{
    transform-origin: left center;
}}

.table-element {{
    border-collapse: collapse;
    border-spacing: 0;
    table-layout: auto;
    margin: 0;
    box-sizing: border-box;
}}

.table-element th,
.table-element td {{
    padding: 8px 12px;
    border: 1px solid #ddd;
    text-align: left;
    vertical-align: top;
    box-sizing: border-box;
}}

.table-element th {{
    background-color: #f8f9fa;
    font-weight: bold;
    border-bottom: 2px solid #333;
}}

.table-element tbody tr:nth-child(even) {{
    background-color: #f9f9f9;
}}

.table-element tbody tr:hover {{
    background-color: #f0f0f0;
}}
"""

    # Helper function to map ReportLab fonts to web fonts
    def get_font_family(font_name):
        if not font_name:
            return "Arial, Helvetica, sans-serif"
        return font_map.get(font_name, font_map.get(font_name.split("-")[0], "Arial, Helvetica, sans-serif"))

    # Helper function to get font weight from font name
    def get_font_weight(font_name):
        if not font_name:
            return "normal"
        if "Bold" in font_name:
            return "bold"
        return "normal"

    # Helper function to get font style from font name
    def get_font_style(font_name):
        if not font_name:
            return "normal"
        if "Oblique" in font_name or "Italic" in font_name:
            return "italic"
        return "normal"

    # Helper function to convert color (supports hex, rgb, named colors)
    def format_color(color):
        if not color:
            return "#000000"
        # If already hex or named color, return as-is
        if color.startswith("#") or color in ["black", "white", "red", "blue", "green", "yellow", "cyan", "magenta"]:
            return color
        # If RGB tuple/list, convert to hex
        if isinstance(color, (list, tuple)) and len(color) >= 3:
            return f"#{int(color[0]):02x}{int(color[1]):02x}{int(color[2]):02x}"
        return str(color)

    # Helper function to render text with Jinja2-like template variables
    def render_text_template(text, data_obj, settings_obj=None):
        """Render text with template variables using actual data"""
        if not text:
            return text

        # Simple template variable replacement for preview
        # Replace {{ variable }} patterns with actual values
        import re

        def replace_var(match):
            var_path = match.group(1).strip()
            try:
                # Handle simple attribute access (e.g., "invoice.invoice_number" or "settings.company_name")
                parts = var_path.split(".")
                if parts[0] == "settings" and settings_obj:
                    # Handle settings variables
                    obj = settings_obj
                    for part in parts[1:]:  # Skip "settings" part
                        obj = getattr(obj, part, None)
                        if obj is None:
                            return match.group(0)  # Return original if not found
                    return str(obj) if obj is not None else ""
                elif data_obj:
                    # Handle invoice/quote variables
                    obj = data_obj
                    for part in parts:
                        obj = getattr(obj, part, None)
                        if obj is None:
                            return match.group(0)  # Return original if not found
                    return str(obj) if obj is not None else ""
                else:
                    return match.group(0)  # Return original if no data object
            except Exception:
                return match.group(0)  # Return original on error

        return re.sub(r"\{\{\s*([^}]+)\s*\}\}", replace_var, text)

    # Build HTML from elements
    html_parts = ['<div class="invoice-wrapper">'] if invoice else ['<div class="quote-wrapper">']

    elements = template_json.get("elements", [])
    for idx, element in enumerate(elements):
        elem_type = element.get("type", "")
        x = element.get("x", 0)
        y = element.get("y", 0)
        style = element.get("style", {})
        opacity = style.get("opacity", 1.0)
        rotation = element.get("rotation", 0)

        # Convert points to pixels at 96 DPI for browser
        x_px = int(x * 96 / 72)
        y_px = int(y * 96 / 72)

        # Base style string
        base_style_parts = [
            f"left: {x_px}px",
            f"top: {y_px}px",
            f"opacity: {opacity}",
        ]

        if rotation:
            base_style_parts.append(f"transform: rotate({rotation}deg)")
            base_style_parts.append("transform-origin: top left")

        style_str_base = "; ".join(base_style_parts)

        if elem_type == "text":
            text = element.get("text", "")
            width = element.get("width", 400)
            height = element.get("height", None)
            width_px_elem = int(width * 96 / 72)

            font_name = style.get("font", "Helvetica")
            font_size = style.get("size", 10)
            color = format_color(style.get("color", "#000000"))
            align = style.get("align", "left")
            valign = style.get("valign", "top")

            # Build complete style string
            style_parts = [style_str_base]
            style_parts.append(f"width: {width_px_elem}px")
            if height:
                style_parts.append(f"height: {int(height * 96 / 72)}px")
            style_parts.append(f"font-family: {get_font_family(font_name)}")
            style_parts.append(f"font-size: {font_size}pt")
            style_parts.append(f"font-weight: {get_font_weight(font_name)}")
            style_parts.append(f"font-style: {get_font_style(font_name)}")
            style_parts.append(f"color: {color}")
            style_parts.append(f"text-align: {align}")
            style_parts.append(f"vertical-align: {valign}")

            style_str = "; ".join(style_parts) + ";"

            # Render text with actual data if available
            data_obj = invoice if invoice else quote
            rendered_text = render_text_template(text, data_obj, settings) if (data_obj or settings) else text

            # Escape HTML but preserve any remaining template syntax
            text_escaped = html_escape.escape(rendered_text)
            # Restore template syntax if any remains (shouldn't after rendering, but just in case)
            text_escaped = text_escaped.replace("&lt;{{", "{{").replace("}}&gt;", "}}")

            html_parts.append(f'<div class="element text-element" style="{style_str}">{text_escaped}</div>')

        elif elem_type == "image":
            width = element.get("width", 100)
            height = element.get("height", 100)
            width_px_elem = int(width * 96 / 72)
            height_px_elem = int(height * 96 / 72)
            source = element.get("source", "")
            is_decorative = element.get("decorative", False)

            # Handle base64 data URLs or file paths
            img_src = ""
            if source.startswith("data:"):
                img_src = source
            elif source.startswith("/uploads/template_images/"):
                # Template image - convert to base64 for PDF generation
                try:
                    from app.utils.template_filters import get_image_base64

                    # Extract filename from URL
                    filename = source.split("/uploads/template_images/")[-1]
                    # Build file path relative to app root (as get_image_base64 expects)
                    relative_path = f"app/static/uploads/template_images/{filename}"
                    # Convert to base64
                    img_src = get_image_base64(relative_path) or source  # Fallback to URL if conversion fails
                except Exception as e:
                    # If conversion fails, use the URL directly
                    current_app.logger.warning(f"Failed to convert template image to base64: {e}")
                    img_src = source
            elif source.startswith("/") or source.startswith("http"):
                img_src = source
            else:
                # Assume it's a relative path or placeholder
                if source:
                    img_src = source
                else:
                    # Placeholder for decorative images without source
                    img_src = "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='100' height='100'%3E%3Crect fill='%23ddd' width='100' height='100'/%3E%3Ctext x='50%25' y='50%25' text-anchor='middle' dy='.3em' fill='%23999'%3EImage%3C/text%3E%3C/svg%3E"

            style_parts = [style_str_base]
            style_parts.append(f"width: {width_px_elem}px")
            style_parts.append(f"height: {height_px_elem}px")
            style_str = "; ".join(style_parts) + ";"

            if img_src and not img_src.startswith("data:image/svg+xml"):
                html_parts.append(
                    f'<img class="element image-element" src="{img_src}" style="{style_str}" alt="Decorative image">'
                )
            else:
                # Show placeholder for decorative images without source
                html_parts.append(
                    f'<div class="element" style="{style_str}background:#f0f0f0;border:2px dashed #999;display:flex;align-items:center;justify-content:center;color:#666;font-size:12px;">Decorative Image</div>'
                )

        elif elem_type == "rectangle":
            width = element.get("width", 100)
            height = element.get("height", 100)
            width_px_elem = int(width * 96 / 72)
            height_px_elem = int(height * 96 / 72)
            fill = format_color(style.get("fill", "#ffffff"))
            stroke = format_color(style.get("stroke", "#000000"))
            stroke_width = style.get("strokeWidth", 1)

            style_parts = [style_str_base]
            style_parts.append(f"width: {width_px_elem}px")
            style_parts.append(f"height: {height_px_elem}px")
            style_parts.append(f"background-color: {fill}")
            if stroke_width > 0:
                style_parts.append(f"border: {stroke_width}px solid {stroke}")
            style_str = "; ".join(style_parts) + ";"

            html_parts.append(f'<div class="element rectangle-element" style="{style_str}"></div>')

        elif elem_type == "circle":
            radius = element.get("radius", 50)
            radius_px = int(radius * 96 / 72)
            fill = format_color(style.get("fill", "#ffffff"))
            stroke = format_color(style.get("stroke", "#000000"))
            stroke_width = style.get("strokeWidth", 1)

            style_parts = [style_str_base]
            style_parts.append(f"width: {radius_px * 2}px")
            style_parts.append(f"height: {radius_px * 2}px")
            style_parts.append(f"background-color: {fill}")
            if stroke_width > 0:
                style_parts.append(f"border: {stroke_width}px solid {stroke}")
            style_str = "; ".join(style_parts) + ";"

            html_parts.append(f'<div class="element circle-element" style="{style_str}"></div>')

        elif elem_type == "line":
            width = element.get("width", 100)
            height = element.get("height", 0)
            # For lines, width is the length, height is the stroke width (thickness)
            width_px_elem = int(width * 96 / 72)
            stroke_width = height if height > 0 else style.get("strokeWidth", 1)
            stroke_width_px = max(1, int(stroke_width * 96 / 72))
            stroke = format_color(style.get("stroke", "#000000"))

            style_parts = [style_str_base]
            style_parts.append(f"width: {width_px_elem}px")
            style_parts.append(f"height: {stroke_width_px}px")
            style_parts.append(f"background-color: {stroke}")
            style_str = "; ".join(style_parts) + ";"

            html_parts.append(f'<div class="element line-element" style="{style_str}"></div>')

        elif elem_type == "table":
            width = element.get("width", 500)
            width_px_elem = int(width * 96 / 72)
            columns = element.get("columns", [])
            row_template = element.get("row_template", {})

            # Get table style properties
            table_style = element.get("style", {})
            border_color = format_color(table_style.get("borderColor", "#000000"))
            header_bg = format_color(table_style.get("headerBackground", "#f8f9fa"))

            style_parts = [style_str_base]
            style_parts.append(f"width: {width_px_elem}px")
            style_parts.append(f"border: 1px solid {border_color}")
            style_str = "; ".join(style_parts) + ";"

            table_html = f'<table class="element table-element" style="{style_str}"><thead><tr>'

            # Build header row
            for col in columns:
                header = col.get("header", "")
                align = col.get("align", "left")
                col_width = col.get("width", None)
                width_attr = f' width="{int(col_width * 96 / 72)}px"' if col_width else ""
                table_html += f'<th style="text-align: {align}; background-color: {header_bg};"{width_attr}>{html_escape.escape(header)}</th>'
            table_html += "</tr></thead><tbody>"

            # Resolve table data from element's data source (e.g. invoice.all_line_items or invoice.items)
            data_obj = invoice if invoice else quote
            items = []
            data_source = element.get("data", "").strip()
            var_name = data_source.replace("{{", "").replace("}}", "").strip() if data_source else ""
            if data_obj and var_name:
                try:
                    parts = var_name.split(".")
                    if len(parts) >= 2 and parts[0] in ("invoice", "quote"):
                        resolved = data_obj
                        for part in parts[1:]:
                            resolved = getattr(resolved, part, None) if resolved is not None else None
                            if resolved is None:
                                break
                        if resolved is not None:
                            if hasattr(resolved, "all"):
                                items = list(resolved.all())
                            elif hasattr(resolved, "__iter__") and not isinstance(resolved, (str, bytes)):
                                items = list(resolved)
                            else:
                                items = [resolved]
                except Exception as e:
                    safe_log(current_app.logger, "warning", "Dashboard data resolution failed: %s", e)
            # Fallback: use data_obj.items (e.g. when data source not set or resolution failed)
            if not items and data_obj and hasattr(data_obj, "items"):
                try:
                    if hasattr(data_obj.items, "all"):
                        items = data_obj.items.all()
                    elif isinstance(data_obj.items, list):
                        items = data_obj.items
                    else:
                        items = list(data_obj.items) if data_obj.items else []
                except Exception as e:
                    safe_log(current_app.logger, "debug", "Dashboard data fallback items failed: %s", e)
                    items = []

            # If no items available, create sample row from template
            if not items and row_template:
                items = [row_template]  # Use template as sample data

            # Render table rows with actual data
            if items:
                for item in items[:10]:  # Limit to 10 rows for preview
                    table_html += "<tr>"
                    for col in columns:
                        field = col.get("field", "")
                        align = col.get("align", "left")
                        # Try to get value from item
                        value = ""
                        try:
                            if hasattr(item, field):
                                value = str(getattr(item, field, ""))
                            elif isinstance(item, dict):
                                value = str(item.get(field, ""))
                            else:
                                value = ""
                        except Exception as e:
                            safe_log(current_app.logger, "debug", "Template value for field %s failed: %s", field, e)
                            value = ""

                        value_escaped = html_escape.escape(str(value))
                        table_html += f'<td style="text-align: {align};">{value_escaped}</td>'
                    table_html += "</tr>"
            else:
                # No data available, show template placeholders
                table_html += "<tr>"
                for col in columns:
                    field = col.get("field", "")
                    align = col.get("align", "left")
                    placeholder = f"{{{{ {field} }}}}"
                    table_html += (
                        f'<td style="text-align: {align}; color: #999;">{html_escape.escape(placeholder)}</td>'
                    )
                table_html += "</tr>"

            table_html += "</tbody></table>"
            html_parts.append(table_html)

    html_parts.append("</div>")
    html = "\n".join(html_parts)

    return html, css


def admin_required(f):
    """Decorator to require admin access

    DEPRECATED: Use @admin_or_permission_required() with specific permissions instead.
    This decorator is kept for backward compatibility.
    """
    from functools import wraps

    @wraps(f)
    def decorated_function(*args, **kwargs):
        if not current_user.is_authenticated or not current_user.is_admin:
            flash(_("Administrator access required"), "error")
            return redirect(url_for("main.dashboard"))
        return f(*args, **kwargs)

    return decorated_function


def allowed_logo_file(filename):
    """Check if the uploaded file has an allowed extension"""
    return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_LOGO_EXTENSIONS


def get_upload_folder():
    """Get the upload folder path for logos"""
    upload_folder = os.path.join(current_app.root_path, "static", "uploads", "logos")
    try:
        os.makedirs(upload_folder, exist_ok=True)
        current_app.logger.info(f"Logo upload folder ensured: {upload_folder}")
    except Exception as e:
        current_app.logger.error(f"Error creating upload folder {upload_folder}: {str(e)}")
        raise
    return upload_folder


@admin_bp.route("/admin")
@login_required
@admin_or_permission_required("access_admin")
def admin_dashboard():
    """Admin dashboard"""
    from datetime import datetime, timedelta

    from sqlalchemy import case, func

    from app.config import Config

    # Get system statistics
    total_users = User.query.count()
    active_users = User.query.filter_by(is_active=True).count()
    total_projects = Project.query.count()
    active_projects = Project.query.filter_by(status="active").count()
    total_entries = TimeEntry.query.filter(TimeEntry.end_time.isnot(None)).count()
    active_timers = TimeEntry.query.filter_by(end_time=None).count()

    # Get recent activity
    recent_entries = (
        TimeEntry.query.filter(TimeEntry.end_time.isnot(None)).order_by(TimeEntry.created_at.desc()).limit(10).all()
    )

    # Get OIDC status
    auth_method = normalize_auth_method(getattr(Config, "AUTH_METHOD", "local"))
    oidc_enabled = auth_includes_oidc(auth_method)
    oidc_issuer = getattr(Config, "OIDC_ISSUER", None)
    oidc_configured = (
        oidc_enabled
        and oidc_issuer
        and getattr(Config, "OIDC_CLIENT_ID", None)
        and getattr(Config, "OIDC_CLIENT_SECRET", None)
    )

    # Count OIDC users
    oidc_users_count = 0
    try:
        oidc_users_count = User.query.filter(User.oidc_issuer.isnot(None), User.oidc_sub.isnot(None)).count()
    except Exception as e:
        # Log error but continue - OIDC user count is not critical for dashboard display
        current_app.logger.warning(f"Failed to count OIDC users: {e}", exc_info=True)

    # Chart data for last 30 days (cached 10 min to reduce DB load)
    from app.utils.cache import get_cache

    _cache = get_cache()
    chart_data = _cache.get("admin:dashboard:chart")
    if chart_data is None:
        from datetime import date as date_type

        end_date = datetime.utcnow()
        range_start = (end_date - timedelta(days=29)).replace(hour=0, minute=0, second=0, microsecond=0)
        range_end = end_date
        all_dates = [(end_date - timedelta(days=i)).date() for i in range(29, -1, -1)]

        def _norm_date(v):
            if v is None:
                return None
            if isinstance(v, date_type):
                return v
            if hasattr(v, "date") and callable(getattr(v, "date")):
                return v.date()
            if isinstance(v, str):
                try:
                    return date_type.fromisoformat(v[:10])
                except (ValueError, TypeError):
                    return v
            return v

        user_activity_rows = (
            db.session.query(
                func.date(TimeEntry.start_time).label("day"),
                func.count(func.distinct(TimeEntry.user_id)).label("cnt"),
            )
            .filter(
                TimeEntry.end_time.isnot(None),
                TimeEntry.start_time >= range_start,
                TimeEntry.start_time <= range_end,
            )
            .group_by(func.date(TimeEntry.start_time))
            .all()
        )
        user_activity_by_date = {_norm_date(d.day): d.cnt for d in user_activity_rows}
        user_activity_data = [
            {"date": d.strftime("%Y-%m-%d"), "count": user_activity_by_date.get(d, 0)} for d in all_dates
        ]

        project_status_data = {}
        status_counts = db.session.query(Project.status, func.count(Project.id)).group_by(Project.status).all()
        for status, count in status_counts:
            project_status_data[status or "none"] = count

        time_hours_rows = (
            db.session.query(
                func.date(TimeEntry.start_time).label("day"),
                func.sum(TimeEntry.duration_seconds).label("total_seconds"),
            )
            .filter(
                TimeEntry.end_time.isnot(None),
                TimeEntry.start_time >= range_start,
                TimeEntry.start_time <= range_end,
            )
            .group_by(func.date(TimeEntry.start_time))
            .all()
        )
        time_hours_by_date = {}
        for row in time_hours_rows:
            day = _norm_date(row.day)
            if day is not None:
                time_hours_by_date[day] = round((row.total_seconds or 0) / 3600, 2)
        time_entries_daily = [
            {"date": d.strftime("%Y-%m-%d"), "hours": time_hours_by_date.get(d, 0)} for d in all_dates
        ]
        chart_data = {
            "user_activity": user_activity_data,
            "project_status": project_status_data,
            "time_entries_daily": time_entries_daily,
        }
        try:
            _cache.set("admin:dashboard:chart", chart_data, ttl=600)
        except Exception as e:
            safe_log(current_app.logger, "debug", "Admin dashboard chart cache set failed: %s", e)

    # Build stats object expected by the template
    stats = {
        "total_users": total_users,
        "active_users": active_users,
        "total_projects": total_projects,
        "active_projects": active_projects,
        "total_entries": total_entries,
        "active_timers": active_timers,
        "total_hours": TimeEntry.get_total_hours_for_period(),
        "billable_hours": TimeEntry.get_total_hours_for_period(billable_only=True),
        "last_backup": None,
    }

    return render_template(
        "admin/dashboard.html",
        stats=stats,
        active_timers=active_timers,
        recent_entries=recent_entries,
        oidc_enabled=oidc_enabled,
        oidc_configured=oidc_configured,
        oidc_auth_method=auth_method,
        oidc_users_count=oidc_users_count,
        chart_data=chart_data,
    )


# Compatibility alias for code/templates that might reference 'admin.dashboard'
@admin_bp.route("/admin/dashboard")
@login_required
@admin_or_permission_required("access_admin")
def admin_dashboard_alias():
    """Alias endpoint so url_for('admin.dashboard') remains valid.

    Some older references may use the endpoint name 'admin.dashboard'.
    Redirect to the canonical admin dashboard endpoint.
    """
    return redirect(url_for("admin.admin_dashboard"))


@admin_bp.route("/admin/users")
@login_required
@admin_or_permission_required("view_users")
def list_users():
    """List all users"""
    users = User.query.order_by(User.username).all()

    # Build stats for users page
    stats = {
        "total_users": User.query.count(),
        "active_users": User.query.filter_by(is_active=True).count(),
        "admin_users": User.query.filter_by(role="admin").count(),
        "total_hours": TimeEntry.get_total_hours_for_period(),
    }

    return render_template("admin/users.html", users=users, stats=stats)


@admin_bp.route("/admin/users/create", methods=["GET", "POST"])
@login_required
@admin_or_permission_required("create_users")
def create_user():
    """Create a new user"""
    if request.method == "POST":
        if current_app.config.get("DEMO_MODE"):
            flash(_("User creation is disabled in demo mode."), "error")
            return redirect(url_for("admin.list_users"))
        username = request.form.get("username", "").strip().lower()
        role_name = request.form.get("role", "user")  # This will be a role name from the Role system
        default_password = request.form.get("default_password", "").strip()
        force_password_change = request.form.get("force_password_change") == "on"

        if not username:
            flash(_("Username is required"), "error")
            all_roles = Role.query.order_by(Role.name).all()
            return render_template("admin/user_form.html", user=None, all_roles=all_roles)

        # Check if user already exists
        if User.query.filter_by(username=username).first():
            flash(_("User already exists"), "error")
            all_roles = Role.query.order_by(Role.name).all()
            return render_template("admin/user_form.html", user=None, all_roles=all_roles)

        # Get the Role object from the database
        role_obj = Role.query.filter_by(name=role_name).first()
        if not role_obj:
            # Fallback: if role doesn't exist, try to use "user" role
            role_obj = Role.query.filter_by(name="user").first()
            if not role_obj:
                flash(_("Default 'user' role not found. Please run 'flask seed_permissions_cmd' first."), "error")
                all_roles = Role.query.order_by(Role.name).all()
                return render_template("admin/user_form.html", user=None, all_roles=all_roles)

        # Create user with legacy role field for backward compatibility
        user = User(username=username, role=role_name)
        # Apply company default for daily working hours (overtime)
        try:
            settings = Settings.get_settings()
            user.standard_hours_per_day = float(getattr(settings, "default_daily_working_hours", 8.0) or 8.0)
        except Exception as e:
            safe_log(current_app.logger, "debug", "Default daily working hours for new user failed: %s", e)

        # Assign the role from the new Role system
        user.roles.append(role_obj)

        # Set default password if provided
        if default_password:
            user.set_password(default_password)
            if force_password_change:
                user.password_change_required = True

        db.session.add(user)
        if not safe_commit("admin_create_user", {"username": username}):
            flash(_("Could not create user due to a database error. Please check server logs."), "error")
            all_roles = Role.query.order_by(Role.name).all()
            return render_template("admin/user_form.html", user=None, all_roles=all_roles)

        flash(_('User "%(username)s" created successfully', username=username), "success")
        return redirect(url_for("admin.list_users"))

    # GET request - show form with available roles
    all_roles = Role.query.order_by(Role.name).all()
    return render_template("admin/user_form.html", user=None, all_roles=all_roles)


@admin_bp.route("/admin/users/<int:user_id>/edit", methods=["GET", "POST"])
@login_required
@admin_or_permission_required("edit_users")
def edit_user(user_id):
    """Edit an existing user"""
    from app.models import Client

    user = User.query.get_or_404(user_id)
    clients = Client.query.filter_by(status="active").order_by(Client.name).all()
    all_roles = Role.query.order_by(Role.name).all()
    assigned_client_ids = [c.id for c in user.assigned_clients.all()]

    if request.method == "POST":
        username = request.form.get("username", "").strip().lower()
        role_name = request.form.get("role", "user")  # This will be a role name from the Role system
        is_active = request.form.get("is_active") == "on"
        client_portal_enabled = request.form.get("client_portal_enabled") == "on"
        client_id = request.form.get("client_id", "").strip()

        if not username:
            flash(_("Username is required"), "error")
            return render_template(
                "admin/user_form.html",
                user=user,
                clients=clients,
                all_roles=all_roles,
                assigned_client_ids=assigned_client_ids,
            )

        # Check if username is already taken by another user
        existing_user = User.query.filter_by(username=username).first()
        if existing_user and existing_user.id != user.id:
            flash(_("Username already exists"), "error")
            return render_template(
                "admin/user_form.html",
                user=user,
                clients=clients,
                all_roles=all_roles,
                assigned_client_ids=assigned_client_ids,
            )

        # Validate client portal settings
        if client_portal_enabled and not client_id:
            flash(_("Please select a client when enabling client portal access."), "error")
            return render_template(
                "admin/user_form.html",
                user=user,
                clients=clients,
                all_roles=all_roles,
                assigned_client_ids=assigned_client_ids,
            )

        # Get the Role object from the database
        role_obj = Role.query.filter_by(name=role_name).first()
        if not role_obj:
            # Fallback: if role doesn't exist, try to use "user" role
            role_obj = Role.query.filter_by(name="user").first()
            if not role_obj:
                flash(_("Default 'user' role not found. Please run 'flask seed_permissions_cmd' first."), "error")
                return render_template(
                    "admin/user_form.html",
                    user=user,
                    clients=clients,
                    all_roles=all_roles,
                    assigned_client_ids=assigned_client_ids,
                )

        # Handle password reset if provided
        new_password = request.form.get("new_password", "").strip()
        password_confirm = request.form.get("password_confirm", "").strip()
        force_password_change = request.form.get("force_password_change") == "on"

        if new_password:
            # Validate password
            if len(new_password) < 8:
                flash(_("Password must be at least 8 characters long."), "error")
                return render_template(
                    "admin/user_form.html",
                    user=user,
                    clients=clients,
                    all_roles=all_roles,
                    assigned_client_ids=assigned_client_ids,
                )

            if new_password != password_confirm:
                flash(_("Passwords do not match."), "error")
                return render_template(
                    "admin/user_form.html",
                    user=user,
                    clients=clients,
                    all_roles=all_roles,
                    assigned_client_ids=assigned_client_ids,
                )

            # Set the new password
            user.set_password(new_password)
            if force_password_change:
                user.password_change_required = True
            else:
                user.password_change_required = False
            current_app.logger.info("Admin '%s' reset password for user '%s'", current_user.username, user.username)

        # Update user
        user.username = username
        # Update legacy role field for backward compatibility
        user.role = role_name

        # Update roles in the new system
        # If user doesn't have the selected role, assign it as the primary role
        # Keep other roles if they exist (multi-role support)
        if role_obj not in user.roles:
            # If user has no roles, assign the selected one
            if not user.roles:
                user.roles.append(role_obj)
            else:
                # If user has roles, replace the first one (primary role) with the selected one
                # This maintains backward compatibility while supporting multi-role
                user.roles[0] = role_obj
        else:
            # If the selected role is already assigned but not first, move it to first position
            if user.roles[0] != role_obj:
                user.roles.remove(role_obj)
                user.roles.insert(0, role_obj)

        user.is_active = is_active
        user.client_portal_enabled = client_portal_enabled
        user.client_id = int(client_id) if client_id else None

        # Subcontractor: sync assigned clients (only when role is subcontractor)
        assigned_client_ids = [int(x) for x in request.form.getlist("assigned_client_ids") if x and x.isdigit()]
        UserClient.query.filter_by(user_id=user.id).delete()
        if role_name == "subcontractor" and assigned_client_ids:
            valid_client_ids = {c.id for c in Client.query.filter(Client.id.in_(assigned_client_ids)).all()}
            for cid in assigned_client_ids:
                if cid in valid_client_ids:
                    db.session.add(UserClient(user_id=user.id, client_id=cid))

        if not safe_commit("admin_edit_user", {"user_id": user.id}):
            flash(_("Could not update user due to a database error. Please check server logs."), "error")
            return render_template(
                "admin/user_form.html",
                user=user,
                clients=clients,
                all_roles=all_roles,
                assigned_client_ids=assigned_client_ids,
            )

        if new_password:
            flash(_('Password reset successfully for user "%(username)s"', username=username), "success")
        else:
            flash(_('User "%(username)s" updated successfully', username=username), "success")
        return redirect(url_for("admin.list_users"))

    return render_template(
        "admin/user_form.html", user=user, clients=clients, all_roles=all_roles, assigned_client_ids=assigned_client_ids
    )


@admin_bp.route("/admin/users/<int:user_id>/delete", methods=["POST"])
@login_required
@admin_or_permission_required("delete_users")
def delete_user(user_id):
    """Delete a user"""
    user = User.query.get_or_404(user_id)

    # Don't allow deleting the last admin
    if user.is_admin:
        admin_count = User.query.filter_by(role="admin", is_active=True).count()
        if admin_count <= 1:
            flash(_("Cannot delete the last administrator"), "error")
            return redirect(url_for("admin.list_users"))

    # Don't allow deleting users with time entries
    if user.time_entries.count() > 0:
        flash(_("Cannot delete user with existing time entries"), "error")
        return redirect(url_for("admin.list_users"))

    # Remove donation_interactions for this user so delete succeeds (FK / optional table)
    try:
        DonationInteraction.query.filter_by(user_id=user.id).delete()
    except ProgrammingError as e:
        error_str = str(e.orig) if hasattr(e, "orig") else str(e)
        if "donation_interactions" in error_str and "does not exist" in error_str:
            current_app.logger.warning(
                "donation_interactions table missing during user delete (user_id=%s); continuing.",
                user.id,
            )
        else:
            raise

    username = user.username
    db.session.delete(user)
    if not safe_commit("admin_delete_user", {"user_id": user.id}):
        flash(_("Could not delete user due to a database error. Please check server logs."), "error")
        return redirect(url_for("admin.list_users"))

    flash(_('User "%(username)s" deleted successfully', username=username), "success")
    return redirect(url_for("admin.list_users"))


@admin_bp.route("/admin/telemetry")
@login_required
@admin_or_permission_required("manage_telemetry")
def telemetry_dashboard():
    """Telemetry and analytics dashboard"""
    installation_config = get_installation_config()
    analytics_config = get_analytics_config()

    # Get telemetry status
    telemetry_data = {
        "enabled": is_telemetry_enabled(),
        "setup_complete": installation_config.is_setup_complete(),
        "installation_id": installation_config.get_installation_id(),
        "telemetry_salt": installation_config.get_installation_salt()[:16] + "...",  # Show partial salt
        "fingerprint": get_telemetry_fingerprint(),
        "config": installation_config.get_all_config(),
    }

    # Get OTEL OTLP status
    grafana_endpoint = analytics_config.get("otel_exporter_otlp_endpoint") or os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "")
    grafana_token = analytics_config.get("otel_exporter_otlp_token") or os.getenv("OTEL_EXPORTER_OTLP_TOKEN", "")
    grafana_data = {
        "enabled": bool(grafana_endpoint) and bool(grafana_token),
        "endpoint": grafana_endpoint,
        "token_set": bool(grafana_token),
    }

    # Get Sentry status
    sentry_dsn = analytics_config.get("sentry_dsn") or os.getenv("SENTRY_DSN", "")
    sentry_data = {
        "enabled": bool(sentry_dsn),
        "dsn_set": bool(sentry_dsn),
        "traces_rate": os.getenv("SENTRY_TRACES_RATE", "0.0"),
    }

    # Log dashboard access
    app_module.log_event("admin.telemetry_dashboard_viewed", user_id=current_user.id)
    app_module.track_event(current_user.id, "admin.telemetry_dashboard_viewed", {})

    return render_template("admin/telemetry.html", telemetry=telemetry_data, grafana=grafana_data, sentry=sentry_data)


@admin_bp.route("/admin/telemetry/toggle", methods=["POST"])
@login_required
@admin_or_permission_required("manage_telemetry")
def toggle_telemetry():
    """Toggle telemetry on/off"""
    installation_config = get_installation_config()
    current_state = installation_config.get_telemetry_preference()
    new_state = not current_state

    installation_config.set_telemetry_preference(new_state)

    if new_state:
        try:
            from app.utils.telemetry import check_and_send_telemetry

            check_and_send_telemetry()
        except Exception as e:
            safe_log(current_app.logger, "debug", "Telemetry check_and_send failed: %s", e)

    app_module.log_event("admin.telemetry_toggled", user_id=current_user.id, new_state=new_state)
    app_module.track_event(current_user.id, "admin.telemetry_toggled", {"enabled": new_state})

    if new_state:
        flash(_("Telemetry has been enabled. Thank you for helping us improve!"), "success")
    else:
        flash(_("Detailed analytics has been disabled. Anonymous base telemetry remains active."), "info")

    return redirect(url_for("admin.telemetry_dashboard"))


@admin_bp.route("/admin/clear-cache")
@login_required
@admin_or_permission_required("manage_settings")
def clear_cache():
    """Cache clearing utility page"""
    return render_template("admin/clear_cache.html")


@admin_bp.route("/admin/modules", methods=["GET", "POST"])
@login_required
@admin_or_permission_required("manage_settings")
def manage_modules():
    """Manage module visibility - enable/disable modules system-wide"""
    from app.models.client import Client
    from app.utils.module_registry import ModuleCategory, ModuleRegistry

    # Initialize registry
    ModuleRegistry.initialize_defaults()

    # Get settings to access disabled_module_ids
    settings_obj = Settings.get_settings()

    # For locked client selection UI
    clients = Client.query.filter_by(status="active").order_by(Client.name).all()

    # Module visibility: non-CORE modules for admin toggles
    modules_by_category = {}
    for cat in ModuleCategory:
        mods = [m for m in ModuleRegistry.get_by_category(cat) if m.category != ModuleCategory.CORE]
        if mods:
            modules_by_category[cat] = mods

    if request.method == "POST":
        # Locked client: allow admin to lock the instance to a single client
        locked_client_id_raw = (request.form.get("locked_client_id") or "").strip()
        if locked_client_id_raw:
            try:
                locked_client_id = int(locked_client_id_raw)
            except (TypeError, ValueError):
                flash(_("Invalid locked client selection."), "error")
                return render_template(
                    "admin/modules.html",
                    modules_by_category=modules_by_category,
                    ModuleCategory=ModuleCategory,
                    settings=settings_obj,
                    clients=clients,
                )

            locked_client = Client.query.get(locked_client_id)
            if not locked_client or getattr(locked_client, "status", None) != "active":
                flash(_("Selected locked client does not exist or is not active."), "error")
                return render_template(
                    "admin/modules.html",
                    modules_by_category=modules_by_category,
                    ModuleCategory=ModuleCategory,
                    settings=settings_obj,
                    clients=clients,
                )
            settings_obj.locked_client_id = locked_client_id
        else:
            settings_obj.locked_client_id = None

        # Module visibility: build disabled_module_ids from unchecked module_enabled_* checkboxes
        if hasattr(settings_obj, "disabled_module_ids"):
            disabled = []
            for mods in modules_by_category.values():
                for m in mods:
                    if ("module_enabled_" + m.id) not in request.form:
                        disabled.append(m.id)

            # Validate module dependencies before saving
            validation_errors = []
            for module_id in disabled:
                can_disable, affected = ModuleRegistry.validate_module_disable(module_id, disabled)
                if not can_disable and affected:
                    module = ModuleRegistry.get(module_id)
                    module_name = module.name if module else module_id
                    affected_names = [
                        ModuleRegistry.get(aid).name if ModuleRegistry.get(aid) else aid for aid in affected
                    ]
                    validation_errors.append(
                        _(
                            "Cannot disable '%(module)s' because the following modules depend on it: %(dependents)s",
                            module=module_name,
                            dependents=", ".join(affected_names),
                        )
                    )

            if validation_errors:
                for error in validation_errors:
                    flash(error, "error")
                return render_template(
                    "admin/modules.html",
                    modules_by_category=modules_by_category,
                    ModuleCategory=ModuleCategory,
                    settings=settings_obj,
                    clients=clients,
                )

            settings_obj.disabled_module_ids = disabled

            # Ensure settings object is in the session
            if settings_obj not in db.session:
                db.session.add(settings_obj)

            if not safe_commit("admin_update_module_visibility"):
                flash(
                    _("Could not update module visibility due to a database error. Please check server logs."), "error"
                )
                return render_template(
                    "admin/modules.html",
                    modules_by_category=modules_by_category,
                    ModuleCategory=ModuleCategory,
                    settings=settings_obj,
                    clients=clients,
                )

            flash(_("Module visibility updated successfully"), "success")
            return redirect(url_for("admin.manage_modules"))

    # Optional module load diagnostics captured during blueprint registration.
    try:
        from flask import current_app

        blueprint_load_status = current_app.extensions.get("blueprint_load_status", []) or []
    except Exception:
        blueprint_load_status = []

    return render_template(
        "admin/modules.html",
        modules_by_category=modules_by_category,
        ModuleCategory=ModuleCategory,
        settings=settings_obj,
        clients=clients,
        blueprint_load_status=blueprint_load_status,
    )


@admin_bp.route("/admin/settings", methods=["GET", "POST"])
@login_required
@admin_or_permission_required("manage_settings")
def settings():
    """Manage system settings"""
    import os  # Ensure os is available in function scope

    settings_obj = Settings.get_settings()
    installation_config = get_installation_config()
    timezones = get_available_timezones()
    peppol_env_enabled = (os.getenv("PEPPOL_ENABLED", "false") or "").strip().lower() in {"1", "true", "yes", "y", "on"}
    ai_config = settings_obj.get_ai_config()

    # Sync analytics preference from installation config to database on load
    # (installation config is the source of truth for telemetry)
    if settings_obj.allow_analytics != installation_config.get_telemetry_preference():
        settings_obj.allow_analytics = installation_config.get_telemetry_preference()
        db.session.commit()

    # Prepare kiosk settings with safe defaults (in case migration hasn't run)
    kiosk_settings = {
        "kiosk_mode_enabled": getattr(settings_obj, "kiosk_mode_enabled", False),
        "kiosk_auto_logout_minutes": getattr(settings_obj, "kiosk_auto_logout_minutes", 15),
        "kiosk_allow_camera_scanning": getattr(settings_obj, "kiosk_allow_camera_scanning", True),
        "kiosk_require_reason_for_adjustments": getattr(settings_obj, "kiosk_require_reason_for_adjustments", False),
        "kiosk_default_movement_type": getattr(settings_obj, "kiosk_default_movement_type", "adjustment"),
    }

    if request.method == "POST":
        # Validate timezone
        timezone = request.form.get("timezone") or settings_obj.timezone
        try:
            from zoneinfo import ZoneInfo, ZoneInfoNotFoundError

            ZoneInfo(timezone)  # This will raise an exception if timezone is invalid
        except (ZoneInfoNotFoundError, KeyError):
            flash(_("Invalid timezone: %(timezone)s", timezone=timezone), "error")
            system_instance_id = Settings.get_system_instance_id()
            return render_template(
                "admin/settings.html",
                settings=settings_obj,
                timezones=timezones,
                kiosk_settings=kiosk_settings,
                peppol_env_enabled=peppol_env_enabled,
                ai_config=ai_config,
                system_instance_id=system_instance_id,
            )

        # Update basic settings
        settings_obj.timezone = timezone

        # Validate and update date/time format
        date_fmt = request.form.get("date_format", "YYYY-MM-DD")
        if date_fmt in ("YYYY-MM-DD", "MM/DD/YYYY", "DD/MM/YYYY", "DD.MM.YYYY"):
            settings_obj.date_format = date_fmt
        time_fmt = request.form.get("time_format", "24h")
        if time_fmt in ("24h", "12h"):
            settings_obj.time_format = time_fmt

        settings_obj.currency = request.form.get("currency", "EUR")
        settings_obj.rounding_minutes = int(request.form.get("rounding_minutes", 1))
        settings_obj.single_active_timer = request.form.get("single_active_timer") == "on"
        settings_obj.allow_self_register = request.form.get("allow_self_register") == "on"
        settings_obj.idle_timeout_minutes = int(request.form.get("idle_timeout_minutes", 30))
        settings_obj.backup_retention_days = int(request.form.get("backup_retention_days", 30))
        settings_obj.backup_time = request.form.get("backup_time", "02:00")
        settings_obj.export_delimiter = request.form.get("export_delimiter", ",")

        # Update company branding settings
        settings_obj.company_name = request.form.get("company_name", "Your Company Name")
        settings_obj.company_address = request.form.get("company_address", "Your Company Address")
        settings_obj.company_email = request.form.get("company_email", "info@yourcompany.com")
        settings_obj.company_phone = request.form.get("company_phone", "+1 (555) 123-4567")
        settings_obj.company_website = request.form.get("company_website", "www.yourcompany.com")
        settings_obj.company_tax_id = request.form.get("company_tax_id", "")
        settings_obj.company_bank_info = request.form.get("company_bank_info", "")

        # Update invoice defaults
        invoice_prefix_form = sanitize_invoice_prefix(request.form.get("invoice_prefix", ""))
        invoice_number_pattern_form = sanitize_invoice_pattern(request.form.get("invoice_number_pattern", ""))
        invoice_start_number_form = request.form.get("invoice_start_number", 1000)
        is_valid_pattern, pattern_error = validate_invoice_pattern(invoice_number_pattern_form)
        if not is_valid_pattern:
            flash(_("Invalid invoice number pattern: %(reason)s", reason=pattern_error), "error")
            system_instance_id = Settings.get_system_instance_id()
            return render_template(
                "admin/settings.html",
                settings=settings_obj,
                timezones=timezones,
                kiosk_settings=kiosk_settings,
                peppol_env_enabled=peppol_env_enabled,
                ai_config=ai_config,
                system_instance_id=system_instance_id,
            )
        # #region agent log
        try:
            import json

            log_data = {
                "location": "admin.py:952",
                "message": "Saving invoice prefix and start number",
                "data": {
                    "invoice_prefix_form": str(invoice_prefix_form),
                    "invoice_number_pattern_form": str(invoice_number_pattern_form),
                    "invoice_start_number_form": str(invoice_start_number_form),
                    "settings_obj_id": settings_obj.id if hasattr(settings_obj, "id") else "NO_ID",
                },
                "timestamp": int(datetime.utcnow().timestamp() * 1000),
                "sessionId": "debug-session",
                "runId": "run1",
                "hypothesisId": "F",
            }
            log_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), ".cursor", "debug.log")
            with open(log_path, "a", encoding="utf-8") as f:
                f.write(json.dumps(log_data) + "\n")
        except (OSError, IOError, TypeError, ValueError):
            pass
        # #endregion
        settings_obj.invoice_prefix = invoice_prefix_form
        settings_obj.invoice_number_pattern = invoice_number_pattern_form
        settings_obj.invoice_start_number = int(invoice_start_number_form)
        settings_obj.invoice_terms = request.form.get("invoice_terms", "Payment is due within 30 days of invoice date.")
        settings_obj.invoice_notes = request.form.get("invoice_notes", "Thank you for your business!")

        # Update Peppol e-invoicing settings (if columns exist)
        try:
            mode = (request.form.get("peppol_enabled_mode") or "env").strip().lower()
            if mode == "true":
                settings_obj.peppol_enabled = True
            elif mode == "false":
                settings_obj.peppol_enabled = False
            else:
                settings_obj.peppol_enabled = None

            settings_obj.peppol_sender_endpoint_id = (request.form.get("peppol_sender_endpoint_id", "") or "").strip()
            settings_obj.peppol_sender_scheme_id = (request.form.get("peppol_sender_scheme_id", "") or "").strip()
            settings_obj.peppol_sender_country = (request.form.get("peppol_sender_country", "") or "").strip()
            settings_obj.peppol_access_point_url = (request.form.get("peppol_access_point_url", "") or "").strip()

            token = (request.form.get("peppol_access_point_token", "") or "").strip()
            if token:
                settings_obj.set_secret("peppol_access_point_token", token)

            try:
                settings_obj.peppol_access_point_timeout = int(request.form.get("peppol_access_point_timeout", 30))
            except Exception:
                settings_obj.peppol_access_point_timeout = 30

            settings_obj.peppol_provider = (request.form.get("peppol_provider", "") or "").strip() or "generic"
            transport_mode = (request.form.get("peppol_transport_mode", "") or "generic").strip().lower()
            if transport_mode not in ("generic", "native"):
                transport_mode = "generic"
            settings_obj.peppol_transport_mode = transport_mode
            settings_obj.peppol_sml_url = (request.form.get("peppol_sml_url", "") or "").strip()
            settings_obj.peppol_native_cert_path = (request.form.get("peppol_native_cert_path", "") or "").strip()
            settings_obj.peppol_native_key_path = (request.form.get("peppol_native_key_path", "") or "").strip()
            settings_obj.invoices_peppol_compliant = request.form.get("invoices_peppol_compliant") == "on"
            settings_obj.invoices_zugferd_pdf = request.form.get("invoices_zugferd_pdf") == "on"
            settings_obj.invoices_pdfa3_compliant = request.form.get("invoices_pdfa3_compliant") == "on"
            settings_obj.invoices_validate_export = request.form.get("invoices_validate_export") == "on"
            settings_obj.invoices_verapdf_path = (request.form.get("invoices_verapdf_path", "") or "").strip()
        except AttributeError:
            # Peppol columns don't exist yet (migration not run)
            pass

        # Update kiosk mode settings (if columns exist)
        try:
            settings_obj.kiosk_mode_enabled = request.form.get("kiosk_mode_enabled") == "on"
            settings_obj.kiosk_auto_logout_minutes = int(request.form.get("kiosk_auto_logout_minutes", 15))
            settings_obj.kiosk_allow_camera_scanning = request.form.get("kiosk_allow_camera_scanning") == "on"
            settings_obj.kiosk_require_reason_for_adjustments = (
                request.form.get("kiosk_require_reason_for_adjustments") == "on"
            )
            settings_obj.kiosk_default_movement_type = request.form.get("kiosk_default_movement_type", "adjustment")
        except AttributeError:
            # Kiosk columns don't exist yet (migration not run)
            pass

        # Update time entry requirements (if columns exist)
        try:
            settings_obj.time_entry_require_task = request.form.get("time_entry_require_task") == "on"
            settings_obj.time_entry_require_description = request.form.get("time_entry_require_description") == "on"
            min_len = int(request.form.get("time_entry_description_min_length", 20))
            settings_obj.time_entry_description_min_length = max(1, min(500, min_len))
        except AttributeError:
            pass

        # Update default daily working hours (overtime) for new users
        try:
            val = request.form.get("default_daily_working_hours", type=float)
            if val is not None and 0.5 <= val <= 24:
                settings_obj.default_daily_working_hours = val
        except (AttributeError, ValueError, TypeError):
            pass

        # Update AI helper settings (server-side provider config; secrets are not exposed to clients)
        try:
            ai_enabled_mode = (request.form.get("ai_enabled_mode") or "env").strip().lower()
            if ai_enabled_mode == "true":
                settings_obj.ai_enabled = True
            elif ai_enabled_mode == "false":
                settings_obj.ai_enabled = False
            else:
                settings_obj.ai_enabled = None

            ai_provider = (request.form.get("ai_provider") or "ollama").strip().lower()
            if ai_provider not in ("ollama", "openai_compatible"):
                ai_provider = "ollama"
            settings_obj.ai_provider = ai_provider
            settings_obj.ai_base_url = (request.form.get("ai_base_url") or "").strip()
            settings_obj.ai_model = (request.form.get("ai_model") or "").strip()
            if request.form.get("ai_clear_api_key") == "on":
                settings_obj.set_secret("ai_api_key", "")
            else:
                ai_api_key = (request.form.get("ai_api_key") or "").strip()
                if ai_api_key:
                    settings_obj.set_secret("ai_api_key", ai_api_key)
            try:
                settings_obj.ai_timeout_seconds = max(1, min(300, int(request.form.get("ai_timeout_seconds") or 30)))
            except (TypeError, ValueError):
                settings_obj.ai_timeout_seconds = None
            try:
                settings_obj.ai_context_limit = max(5, min(200, int(request.form.get("ai_context_limit") or 40)))
            except (TypeError, ValueError):
                settings_obj.ai_context_limit = None
            settings_obj.ai_system_prompt = (request.form.get("ai_system_prompt") or "").strip()
        except AttributeError:
            pass

        # Update privacy and analytics settings
        allow_analytics = request.form.get("allow_analytics") == "on"
        old_analytics_state = settings_obj.allow_analytics
        settings_obj.allow_analytics = allow_analytics

        # Also update the installation config (used by telemetry system)
        # This ensures the telemetry system sees the updated preference
        installation_config.set_telemetry_preference(allow_analytics)

        # Log analytics preference change if it changed
        if old_analytics_state != allow_analytics:
            app_module.log_event("admin.analytics_toggled", user_id=current_user.id, new_state=allow_analytics)
            app_module.track_event(current_user.id, "admin.analytics_toggled", {"enabled": allow_analytics})

        # Ensure settings object is in the session (important for new instances)
        if settings_obj not in db.session:
            db.session.add(settings_obj)

        if not safe_commit("admin_update_settings"):
            flash(_("Could not update settings due to a database error. Please check server logs."), "error")
            system_instance_id = Settings.get_system_instance_id()
            return render_template(
                "admin/settings.html",
                settings=settings_obj,
                timezones=timezones,
                kiosk_settings=kiosk_settings,
                peppol_env_enabled=peppol_env_enabled,
                ai_config=ai_config,
                system_instance_id=system_instance_id,
            )
        # #region agent log
        try:
            import json

            log_data = {
                "location": "admin.py:1027",
                "message": "After commit - settings values",
                "data": {
                    "invoice_prefix": str(settings_obj.invoice_prefix),
                    "invoice_number_pattern": str(getattr(settings_obj, "invoice_number_pattern", "")),
                    "invoice_start_number": int(settings_obj.invoice_start_number),
                    "settings_obj_id": settings_obj.id if hasattr(settings_obj, "id") else "NO_ID",
                },
                "timestamp": int(datetime.utcnow().timestamp() * 1000),
                "sessionId": "debug-session",
                "runId": "run1",
                "hypothesisId": "G",
            }
            log_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), ".cursor", "debug.log")
            with open(log_path, "a", encoding="utf-8") as f:
                f.write(json.dumps(log_data) + "\n")
        except (OSError, IOError, TypeError, ValueError):
            pass
        # #endregion
        flash(_("Settings updated successfully"), "success")
        return redirect(url_for("admin.settings"))

    # Update kiosk_settings after potential POST update
    kiosk_settings = {
        "kiosk_mode_enabled": getattr(settings_obj, "kiosk_mode_enabled", False),
        "kiosk_auto_logout_minutes": getattr(settings_obj, "kiosk_auto_logout_minutes", 15),
        "kiosk_allow_camera_scanning": getattr(settings_obj, "kiosk_allow_camera_scanning", True),
        "kiosk_require_reason_for_adjustments": getattr(settings_obj, "kiosk_require_reason_for_adjustments", False),
        "kiosk_default_movement_type": getattr(settings_obj, "kiosk_default_movement_type", "adjustment"),
    }

    system_instance_id = Settings.get_system_instance_id()
    ai_config = settings_obj.get_ai_config()
    return render_template(
        "admin/settings.html",
        settings=settings_obj,
        timezones=timezones,
        kiosk_settings=kiosk_settings,
        peppol_env_enabled=peppol_env_enabled,
        ai_config=ai_config,
        system_instance_id=system_instance_id,
    )


@admin_bp.route("/admin/ldap/test", methods=["POST"])
@limiter.limit("10 per minute")
@login_required
@admin_or_permission_required("manage_settings")
def admin_ldap_test():
    """Test LDAP connectivity (service bind + user subtree count). Returns JSON only."""
    from app.services.ldap_service import LDAPService

    return jsonify(LDAPService.test_connection())


@admin_bp.route("/admin/settings/verify-donate-hide-code", methods=["POST"])
@login_required
@admin_or_permission_required("manage_settings")
def admin_verify_donate_hide_code():
    """Verify code (Ed25519 or HMAC) and set system-wide donate_ui_hidden=True."""
    import hmac

    from app.utils.donate_hide_code import compute_donate_hide_code, verify_ed25519_signature

    settings_obj = Settings.get_settings()
    if getattr(settings_obj, "donate_ui_hidden", False):
        return jsonify({"success": True})

    data = request.get_json() or {}
    code = (data.get("code") or "").strip()
    system_id = Settings.get_system_instance_id()
    if not system_id:
        return jsonify({"error": _("Invalid code.")}), 400

    valid = False
    public_key_pem = current_app.config.get("DONATE_HIDE_PUBLIC_KEY_PEM") or ""
    if public_key_pem:
        valid = verify_ed25519_signature(code, system_id, public_key_pem)
    if not valid:
        secret = current_app.config.get("DONATE_HIDE_UNLOCK_SECRET") or ""
        if secret:
            expected = compute_donate_hide_code(secret, system_id)
            valid = bool(expected and hmac.compare_digest(code, expected))

    if not valid:
        return jsonify({"error": _("Invalid code.")}), 400

    settings_obj.donate_ui_hidden = True
    if safe_commit(db.session):
        return jsonify({"success": True})
    return jsonify({"error": _("Error saving settings")}), 500


@admin_bp.route("/admin/pdf-layout", methods=["GET", "POST"])
@limiter.limit("30 per minute", methods=["POST"])  # editor saves
@login_required
@admin_or_permission_required("manage_settings")
def pdf_layout():
    """Edit PDF invoice layout template (HTML and CSS) by page size."""
    from app.models import InvoicePDFTemplate

    # Get page size from query parameter or form, default to A4
    page_size_raw = request.args.get("size", request.form.get("page_size", "A4"))
    current_app.logger.info(
        f"[PDF_TEMPLATE] Action: template_editor_request, PageSize: '{page_size_raw}', Method: {request.method}, User: {current_user.username}"
    )

    # Ensure valid page size
    valid_sizes = ["A4", "Letter", "Legal", "A3", "A5", "Tabloid"]
    if page_size_raw not in valid_sizes:
        current_app.logger.warning(
            f"[PDF_TEMPLATE] Invalid page size '{page_size_raw}', defaulting to A4, User: {current_user.username}"
        )
        page_size = "A4"
    else:
        page_size = page_size_raw

    current_app.logger.info(
        f"[PDF_TEMPLATE] Final validated PageSize: '{page_size}', Method: {request.method}, User: {current_user.username}"
    )

    # Get or create template for this page size (ensures JSON exists)
    current_app.logger.info(
        f"[PDF_TEMPLATE] Retrieving template from database - PageSize: '{page_size}', User: {current_user.username}"
    )
    template = InvoicePDFTemplate.get_template(page_size)
    current_app.logger.info(
        f"[PDF_TEMPLATE] Template retrieved - PageSize: '{page_size}', TemplateID: {template.id}, HasJSON: {bool(template.template_json)}, HasDesignJSON: {bool(template.design_json)}"
    )

    if request.method == "POST":
        current_app.logger.info(
            f"[PDF_TEMPLATE] Action: template_save, PageSize: '{page_size}', User: {current_user.username}"
        )
        html_template = request.form.get("invoice_pdf_template_html", "")
        css_template = request.form.get("invoice_pdf_template_css", "")
        design_json = request.form.get("design_json", "")
        template_json = request.form.get("template_json", "")  # ReportLab template JSON
        date_format = request.form.get("date_format", "%d.%m.%Y")  # Date format for this template

        current_app.logger.info(
            f"[PDF_TEMPLATE] Form data received - PageSize: '{page_size}', HTML length: {len(html_template)}, CSS length: {len(css_template)}, DesignJSON length: {len(design_json)}, TemplateJSON length: {len(template_json)}"
        )

        # Validate and ensure template_json is present
        import json

        template_json_dict = None
        if template_json and template_json.strip():
            try:
                current_app.logger.info(
                    f"[PDF_TEMPLATE] Parsing template JSON - PageSize: '{page_size}', JSON length: {len(template_json)}"
                )
                template_json_dict = json.loads(template_json)
                # Ensure page size matches in JSON
                json_page_size = template_json_dict.get("page", {}).get("size")
                current_app.logger.info(
                    f"[PDF_TEMPLATE] Template JSON page size before update: '{json_page_size}', Target PageSize: '{page_size}'"
                )
                if "page" in template_json_dict and "size" in template_json_dict["page"]:
                    template_json_dict["page"]["size"] = page_size
                else:
                    # Add page size if missing
                    if "page" not in template_json_dict:
                        template_json_dict["page"] = {}
                    template_json_dict["page"]["size"] = page_size

                # CRITICAL: Ensure page dimensions (width/height) match the page size
                # This fixes layout issues when templates are customized
                from app.utils.pdf_template_schema import get_page_dimensions_mm

                template_page_config = template_json_dict.get("page", {})
                expected_dims = get_page_dimensions_mm(page_size)
                current_width = template_page_config.get("width")
                current_height = template_page_config.get("height")

                if current_width != expected_dims["width"] or current_height != expected_dims["height"]:
                    current_app.logger.info(
                        f"[PDF_TEMPLATE] Updating template page dimensions - PageSize: '{page_size}', "
                        f"Old: {current_width}x{current_height}mm, New: {expected_dims['width']}x{expected_dims['height']}mm, User: {current_user.username}"
                    )
                    template_page_config["width"] = expected_dims["width"]
                    template_page_config["height"] = expected_dims["height"]
                    template_json_dict["page"] = template_page_config

                template_json = json.dumps(template_json_dict)
                element_count = len(template_json_dict.get("elements", []))
                current_app.logger.info(
                    f"[PDF_TEMPLATE] Template JSON parsed and updated - PageSize: '{page_size}', Elements: {element_count}, JSON length: {len(template_json)}"
                )
            except json.JSONDecodeError as e:
                current_app.logger.error(
                    f"[PDF_TEMPLATE] Invalid template_json provided - PageSize: '{page_size}', Error: {str(e)}, User: {current_user.username}"
                )
                flash(_("Invalid template JSON format. Please try again."), "error")
                return redirect(url_for("admin.pdf_layout", size=page_size))
        else:
            # If no template_json provided, generate default
            current_app.logger.warning(
                f"[PDF_TEMPLATE] No template_json provided, generating default - PageSize: '{page_size}', User: {current_user.username}"
            )
            from app.utils.pdf_template_schema import get_default_template

            template_json_dict = get_default_template(page_size)
            template_json = json.dumps(template_json_dict)
            element_count = len(template_json_dict.get("elements", []))
            current_app.logger.info(
                f"[PDF_TEMPLATE] Generated default template JSON - PageSize: '{page_size}', Elements: {element_count}, User: {current_user.username}"
            )

        # Normalize @page size in CSS to match the selected page size before saving
        # This ensures that saved templates always have the correct page size
        if css_template:
            from app.utils.pdf_generator import update_page_size_in_css, validate_page_size_in_css

            current_app.logger.info(
                f"[PDF_TEMPLATE] Normalizing CSS @page size - PageSize: '{page_size}', CSS length: {len(css_template)}"
            )
            css_template = update_page_size_in_css(css_template, page_size)

            # Validate after normalization
            is_valid, found_sizes = validate_page_size_in_css(css_template, page_size)
            if not is_valid:
                current_app.logger.warning(
                    f"[PDF_TEMPLATE] CSS @page size normalization issue - PageSize: '{page_size}', Found sizes: {found_sizes}, User: {current_user.username}"
                )
            else:
                current_app.logger.info(
                    f"[PDF_TEMPLATE] CSS @page size normalized successfully - PageSize: '{page_size}'"
                )

        # Validate template_json before saving
        if not template_json or not template_json.strip():
            current_app.logger.error(
                f"[PDF_TEMPLATE] ERROR: template_json is empty - PageSize: '{page_size}', TemplateID: {template.id}, User: {current_user.username}"
            )
            flash(_("Error: Template JSON is empty. Please try saving again."), "error")
            return redirect(url_for("admin.pdf_layout", size=page_size))

        # Validate that template_json is valid JSON
        try:
            import json

            template_json_dict_validate = json.loads(template_json)
            if not isinstance(template_json_dict_validate, dict) or "page" not in template_json_dict_validate:
                current_app.logger.error(
                    f"[PDF_TEMPLATE] ERROR: template_json is invalid (missing 'page' property) - PageSize: '{page_size}', TemplateID: {template.id}, User: {current_user.username}"
                )
                flash(_("Error: Template JSON is invalid. Please try saving again."), "error")
                return redirect(url_for("admin.pdf_layout", size=page_size))
            element_count = len(template_json_dict_validate.get("elements", []))
            current_app.logger.info(
                f"[PDF_TEMPLATE] Template JSON validated before save - PageSize: '{page_size}', Elements: {element_count}, JSON length: {len(template_json)}, TemplateID: {template.id}, User: {current_user.username}"
            )
        except json.JSONDecodeError as e:
            current_app.logger.error(
                f"[PDF_TEMPLATE] ERROR: template_json is not valid JSON - PageSize: '{page_size}', TemplateID: {template.id}, Error: {str(e)}, User: {current_user.username}"
            )
            flash(_("Error: Template JSON is not valid JSON. Please try saving again."), "error")
            return redirect(url_for("admin.pdf_layout", size=page_size))

        # Update template (save both legacy HTML/CSS and new JSON format)
        current_app.logger.info(
            f"[PDF_TEMPLATE] Updating template in database - PageSize: '{page_size}', TemplateID: {template.id}, User: {current_user.username}"
        )
        template.template_html = html_template
        template.template_css = css_template
        template.design_json = design_json
        template.template_json = template_json  # ReportLab template JSON (always present now)
        template.date_format = date_format  # Date format for this template
        template.updated_at = datetime.utcnow()

        # For backwards compatibility, also update Settings when saving A4 (default)
        if page_size == "A4":
            current_app.logger.info(
                f"[PDF_TEMPLATE] Also updating Settings for A4 default - User: {current_user.username}"
            )
            settings_obj = Settings.get_settings()
            settings_obj.invoice_pdf_template_html = html_template
            settings_obj.invoice_pdf_template_css = css_template
            settings_obj.invoice_pdf_design_json = design_json

        current_app.logger.info(
            f"[PDF_TEMPLATE] Committing template to database - PageSize: '{page_size}', TemplateID: {template.id}, User: {current_user.username}"
        )
        if not safe_commit("admin_update_pdf_layout"):
            from flask_babel import gettext as _

            current_app.logger.error(
                f"[PDF_TEMPLATE] Database commit failed - PageSize: '{page_size}', TemplateID: {template.id}, User: {current_user.username}"
            )
            flash(_("Could not update PDF layout due to a database error."), "error")
        else:
            from flask_babel import gettext as _

            # Verify that template_json was actually saved
            db.session.refresh(template)
            if template.template_json and template.template_json.strip() and template.template_json == template_json:
                current_app.logger.info(
                    f"[PDF_TEMPLATE] Template saved successfully - PageSize: '{page_size}', TemplateID: {template.id}, HasJSON: True, JSON length: {len(template.template_json)}, User: {current_user.username}"
                )
                flash(_("PDF layout updated successfully"), "success")
            else:
                current_app.logger.error(
                    f"[PDF_TEMPLATE] WARNING: Template saved but template_json verification failed - PageSize: '{page_size}', TemplateID: {template.id}, HasJSON: {bool(template.template_json)}, User: {current_user.username}"
                )
                flash(
                    _("PDF layout saved but template JSON verification failed. Please check the template."), "warning"
                )
        return redirect(url_for("admin.pdf_layout", size=page_size))

    # Get all templates for dropdown
    all_templates = InvoicePDFTemplate.get_all_templates()
    current_app.logger.info(
        f"[PDF_TEMPLATE] Loaded all templates for dropdown - Count: {len(all_templates)}, PageSize: '{page_size}', User: {current_user.username}"
    )

    # DON'T call ensure_template_json() here - it may overwrite saved templates
    # Template should already have JSON if it was saved properly
    # Only validate JSON if it exists - don't generate defaults that might overwrite saved templates
    if template.template_json:
        try:
            import json

            template_json_check = json.loads(template.template_json)
            element_count = len(template_json_check.get("elements", []))
            json_page_size = template_json_check.get("page", {}).get("size", "unknown")
            current_app.logger.info(
                f"[PDF_TEMPLATE] Template JSON validated - PageSize: '{page_size}', JSON PageSize: '{json_page_size}', Elements: {element_count}, TemplateID: {template.id}"
            )
        except Exception as e:
            current_app.logger.warning(
                f"[PDF_TEMPLATE] Template JSON validation check failed - PageSize: '{page_size}', Error: {str(e)}, TemplateID: {template.id}"
            )

    # Provide initial defaults to the template if no custom HTML/CSS saved
    initial_html = template.template_html or ""
    initial_css = template.template_css or ""
    design_json = template.design_json or ""
    template_json = template.template_json or ""
    current_app.logger.info(
        f"[PDF_TEMPLATE] Template loaded for editor - PageSize: '{page_size}', HTML length: {len(initial_html)}, CSS length: {len(initial_css)}, DesignJSON length: {len(design_json)}, TemplateJSON length: {len(template_json)}, TemplateID: {template.id}"
    )

    # Fallback to legacy Settings if template is empty
    if not initial_html and not initial_css:
        settings_obj = Settings.get_settings()
        initial_html = settings_obj.invoice_pdf_template_html or ""
        initial_css = settings_obj.invoice_pdf_template_css or ""
        design_json = settings_obj.invoice_pdf_design_json or ""

    # Load default template if still empty
    try:
        if not initial_html:
            env = current_app.jinja_env
            html_src, _, _ = env.loader.get_source(env, "invoices/pdf_default.html")
            # Extract body only for editor
            try:
                import re as _re

                m = _re.search(r"<body[^>]*>([\s\S]*?)</body>", html_src, _re.IGNORECASE)
                initial_html = m.group(1).strip() if m else html_src
            except Exception as e:
                # Log but continue - template parsing failure is not critical
                current_app.logger.debug(f"Failed to parse PDF template HTML: {e}")
        if not initial_css:
            try:
                env = current_app.jinja_env
                css_src, _, _ = env.loader.get_source(env, "invoices/pdf_styles_default.css")
                initial_css = css_src
            except Exception as e:
                # Log but continue - CSS loading failure is not critical
                current_app.logger.debug(f"Failed to load default PDF CSS: {e}")
    except Exception as e:
        # Log but continue - PDF layout initialization failure is not critical
        current_app.logger.warning(f"Failed to initialize PDF layout defaults: {e}", exc_info=True)

    # Normalize @page size in initial CSS to match the selected page size
    # This ensures the editor always shows the correct page size
    if initial_css:
        from app.utils.pdf_generator import update_page_size_in_css

        initial_css = update_page_size_in_css(initial_css, page_size)

    return render_template(
        "admin/pdf_layout.html",
        settings=Settings.get_settings(),
        initial_html=initial_html,
        initial_css=initial_css,
        design_json=design_json,
        template_json=template_json,
        page_size=page_size,
        all_templates=all_templates,
        date_format=getattr(template, "date_format", None) or "%d.%m.%Y",
    )


@admin_bp.route("/admin/pdf-layout/reset", methods=["POST"])
@limiter.limit("10 per minute")
@login_required
@admin_or_permission_required("manage_settings")
def pdf_layout_reset():
    """Reset PDF layout to defaults (clear custom templates and regenerate default JSON)."""
    import json

    from app.models import InvoicePDFTemplate
    from app.utils.pdf_template_schema import get_default_template

    # Get page size from query parameter or form, default to A4
    page_size = request.args.get("size", request.form.get("page_size", "A4"))

    # Ensure valid page size
    valid_sizes = ["A4", "Letter", "Legal", "A3", "A5", "Tabloid"]
    if page_size not in valid_sizes:
        page_size = "A4"

    # Get or create template for this page size
    template = InvoicePDFTemplate.get_template(page_size)

    # Clear custom templates
    template.template_html = ""
    template.template_css = ""
    template.design_json = ""

    # Regenerate default JSON template
    default_json = get_default_template(page_size)
    template.template_json = json.dumps(default_json)
    template.updated_at = datetime.utcnow()

    # Also clear legacy Settings for A4
    if page_size == "A4":
        settings_obj = Settings.get_settings()
        settings_obj.invoice_pdf_template_html = ""
        settings_obj.invoice_pdf_template_css = ""
        settings_obj.invoice_pdf_design_json = ""

    if not safe_commit("admin_reset_pdf_layout"):
        flash(_("Could not reset PDF layout due to a database error."), "error")
    else:
        flash(_("PDF layout reset to defaults"), "success")
    return redirect(url_for("admin.pdf_layout", size=page_size))


@admin_bp.route("/admin/quote-pdf-layout", methods=["GET", "POST"])
@limiter.limit("30 per minute")
@login_required
@admin_or_permission_required("manage_settings")
def quote_pdf_layout():
    """Edit PDF quote layout template (HTML and CSS) by page size."""
    from app.models import QuotePDFTemplate

    # Get page size from query parameter or form, default to A4
    page_size_raw = request.args.get("size", request.form.get("page_size", "A4"))
    current_app.logger.info(
        f"[PDF_TEMPLATE] Action: quote_template_editor_request, PageSize: '{page_size_raw}', Method: {request.method}, User: {current_user.username}"
    )

    # Ensure valid page size
    valid_sizes = ["A4", "Letter", "Legal", "A3", "A5", "Tabloid"]
    if page_size_raw not in valid_sizes:
        current_app.logger.warning(
            f"[PDF_TEMPLATE] Invalid page size '{page_size_raw}', defaulting to A4, User: {current_user.username}"
        )
        page_size = "A4"
    else:
        page_size = page_size_raw

    current_app.logger.info(
        f"[PDF_TEMPLATE] Final validated PageSize: '{page_size}', Method: {request.method}, User: {current_user.username}"
    )

    # Get or create template for this page size (ensures JSON exists)
    current_app.logger.info(
        f"[PDF_TEMPLATE] Retrieving quote template from database - PageSize: '{page_size}', User: {current_user.username}"
    )
    template = QuotePDFTemplate.get_template(page_size)
    current_app.logger.info(
        f"[PDF_TEMPLATE] Quote template retrieved - PageSize: '{page_size}', TemplateID: {template.id}, HasJSON: {bool(template.template_json)}, HasDesignJSON: {bool(template.design_json)}"
    )

    if request.method == "POST":
        current_app.logger.info(
            f"[PDF_TEMPLATE] Action: quote_template_save, PageSize: '{page_size}', User: {current_user.username}"
        )
        html_template = request.form.get("quote_pdf_template_html", "")
        css_template = request.form.get("quote_pdf_template_css", "")
        design_json = request.form.get("design_json", "")
        template_json = request.form.get("template_json", "")  # ReportLab template JSON
        date_format = request.form.get("date_format", "%d.%m.%Y")  # Date format for this template

        current_app.logger.info(
            f"[PDF_TEMPLATE] Form data received - PageSize: '{page_size}', HTML length: {len(html_template)}, CSS length: {len(css_template)}, DesignJSON length: {len(design_json)}, TemplateJSON length: {len(template_json)}"
        )

        # Validate and ensure template_json is present
        import json

        template_json_dict = None
        if template_json and template_json.strip():
            try:
                current_app.logger.info(
                    f"[PDF_TEMPLATE] Parsing quote template JSON - PageSize: '{page_size}', JSON length: {len(template_json)}"
                )
                template_json_dict = json.loads(template_json)
                # Ensure page size matches in JSON
                json_page_size = template_json_dict.get("page", {}).get("size")
                current_app.logger.info(
                    f"[PDF_TEMPLATE] Quote template JSON page size before update: '{json_page_size}', Target PageSize: '{page_size}'"
                )
                if "page" in template_json_dict and "size" in template_json_dict["page"]:
                    template_json_dict["page"]["size"] = page_size
                else:
                    # Add page size if missing
                    if "page" not in template_json_dict:
                        template_json_dict["page"] = {}
                    template_json_dict["page"]["size"] = page_size

                # CRITICAL: Ensure page dimensions (width/height) match the page size
                # This fixes layout issues when templates are customized
                from app.utils.pdf_template_schema import get_page_dimensions_mm

                template_page_config = template_json_dict.get("page", {})
                expected_dims = get_page_dimensions_mm(page_size)
                current_width = template_page_config.get("width")
                current_height = template_page_config.get("height")

                if current_width != expected_dims["width"] or current_height != expected_dims["height"]:
                    current_app.logger.info(
                        f"[PDF_TEMPLATE] Updating quote template page dimensions - PageSize: '{page_size}', "
                        f"Old: {current_width}x{current_height}mm, New: {expected_dims['width']}x{expected_dims['height']}mm, User: {current_user.username}"
                    )
                    template_page_config["width"] = expected_dims["width"]
                    template_page_config["height"] = expected_dims["height"]
                    template_json_dict["page"] = template_page_config

                template_json = json.dumps(template_json_dict)
                element_count = len(template_json_dict.get("elements", []))
                current_app.logger.info(
                    f"[PDF_TEMPLATE] Quote template JSON parsed and updated - PageSize: '{page_size}', Elements: {element_count}, JSON length: {len(template_json)}"
                )
            except json.JSONDecodeError as e:
                current_app.logger.error(
                    f"[PDF_TEMPLATE] Invalid quote template_json provided - PageSize: '{page_size}', Error: {str(e)}, User: {current_user.username}"
                )
                flash(_("Invalid template JSON format. Please try again."), "error")
                return redirect(url_for("admin.quote_pdf_layout", size=page_size))
        else:
            # If no template_json provided, generate default
            current_app.logger.warning(
                f"[PDF_TEMPLATE] No quote template_json provided, generating default - PageSize: '{page_size}', User: {current_user.username}"
            )
            from app.utils.pdf_template_schema import get_default_template

            template_json_dict = get_default_template(page_size)
            template_json = json.dumps(template_json_dict)
            element_count = len(template_json_dict.get("elements", []))
            current_app.logger.info(
                f"[PDF_TEMPLATE] Generated default quote template JSON - PageSize: '{page_size}', Elements: {element_count}, User: {current_user.username}"
            )

        # Normalize @page size in CSS to match the selected page size before saving
        # This ensures that saved templates always have the correct page size
        if css_template:
            from app.utils.pdf_generator import update_page_size_in_css, validate_page_size_in_css

            current_app.logger.info(
                f"[PDF_TEMPLATE] Normalizing quote CSS @page size - PageSize: '{page_size}', CSS length: {len(css_template)}"
            )
            css_template = update_page_size_in_css(css_template, page_size)

            # Validate after normalization
            is_valid, found_sizes = validate_page_size_in_css(css_template, page_size)
            if not is_valid:
                current_app.logger.warning(
                    f"[PDF_TEMPLATE] Quote CSS @page size normalization issue - PageSize: '{page_size}', Found sizes: {found_sizes}, User: {current_user.username}"
                )
            else:
                current_app.logger.info(
                    f"[PDF_TEMPLATE] Quote CSS @page size normalized successfully - PageSize: '{page_size}'"
                )

        # Update template (save both legacy HTML/CSS and new JSON format)
        current_app.logger.info(
            f"[PDF_TEMPLATE] Updating quote template in database - PageSize: '{page_size}', TemplateID: {template.id}, User: {current_user.username}"
        )
        template.template_html = html_template
        template.template_css = css_template
        template.design_json = design_json
        # Validate template_json before saving
        if not template_json or not template_json.strip():
            current_app.logger.error(
                f"[PDF_TEMPLATE] ERROR: Quote template_json is empty - PageSize: '{page_size}', TemplateID: {template.id}, User: {current_user.username}"
            )
            flash(_("Error: Template JSON is empty. Please try saving again."), "error")
            return redirect(url_for("admin.quote_pdf_layout", size=page_size))

        # Validate that template_json is valid JSON
        try:
            import json

            template_json_dict_validate = json.loads(template_json)
            if not isinstance(template_json_dict_validate, dict) or "page" not in template_json_dict_validate:
                current_app.logger.error(
                    f"[PDF_TEMPLATE] ERROR: Quote template_json is invalid (missing 'page' property) - PageSize: '{page_size}', TemplateID: {template.id}, User: {current_user.username}"
                )
                flash(_("Error: Template JSON is invalid. Please try saving again."), "error")
                return redirect(url_for("admin.quote_pdf_layout", size=page_size))
            element_count = len(template_json_dict_validate.get("elements", []))
            current_app.logger.info(
                f"[PDF_TEMPLATE] Quote template JSON validated before save - PageSize: '{page_size}', Elements: {element_count}, JSON length: {len(template_json)}, TemplateID: {template.id}, User: {current_user.username}"
            )
        except json.JSONDecodeError as e:
            current_app.logger.error(
                f"[PDF_TEMPLATE] ERROR: Quote template_json is not valid JSON - PageSize: '{page_size}', TemplateID: {template.id}, Error: {str(e)}, User: {current_user.username}"
            )
            flash(_("Error: Template JSON is not valid JSON. Please try saving again."), "error")
            return redirect(url_for("admin.quote_pdf_layout", size=page_size))

        template.template_json = template_json  # ReportLab template JSON (always present now)
        template.date_format = date_format  # Date format for this template
        template.updated_at = datetime.utcnow()

        current_app.logger.info(
            f"[PDF_TEMPLATE] Committing quote template to database - PageSize: '{page_size}', TemplateID: {template.id}, User: {current_user.username}"
        )
        if not safe_commit("admin_update_quote_pdf_layout"):
            current_app.logger.error(
                f"[PDF_TEMPLATE] Quote template database commit failed - PageSize: '{page_size}', TemplateID: {template.id}, User: {current_user.username}"
            )
            flash(_("Could not update PDF layout due to a database error."), "error")
        else:
            # Verify that template_json was actually saved
            db.session.refresh(template)
            if template.template_json and template.template_json.strip() and template.template_json == template_json:
                current_app.logger.info(
                    f"[PDF_TEMPLATE] Quote template saved successfully - PageSize: '{page_size}', TemplateID: {template.id}, HasJSON: True, JSON length: {len(template.template_json)}, User: {current_user.username}"
                )
                flash(_("PDF layout updated successfully"), "success")
            else:
                current_app.logger.error(
                    f"[PDF_TEMPLATE] WARNING: Quote template saved but template_json verification failed - PageSize: '{page_size}', TemplateID: {template.id}, HasJSON: {bool(template.template_json)}, User: {current_user.username}"
                )
                flash(
                    _("PDF layout saved but template JSON verification failed. Please check the template."), "warning"
                )
        return redirect(url_for("admin.quote_pdf_layout", size=page_size))

    # Get all templates for dropdown
    all_templates = QuotePDFTemplate.get_all_templates()
    current_app.logger.info(
        f"[PDF_TEMPLATE] Loaded all quote templates for dropdown - Count: {len(all_templates)}, PageSize: '{page_size}', User: {current_user.username}"
    )

    # DON'T call ensure_template_json() here - it may overwrite saved templates
    # Template should already have JSON if it was saved properly
    # Only validate JSON if it exists - don't generate defaults that might overwrite saved templates
    if template.template_json:
        try:
            import json

            template_json_check = json.loads(template.template_json)
            element_count = len(template_json_check.get("elements", []))
            json_page_size = template_json_check.get("page", {}).get("size", "unknown")
            current_app.logger.info(
                f"[PDF_TEMPLATE] Quote template JSON validated - PageSize: '{page_size}', JSON PageSize: '{json_page_size}', Elements: {element_count}, TemplateID: {template.id}"
            )
        except Exception as e:
            current_app.logger.warning(
                f"[PDF_TEMPLATE] Quote template JSON validation check failed - PageSize: '{page_size}', Error: {str(e)}, TemplateID: {template.id}"
            )

    # Provide initial defaults
    initial_html = template.template_html or ""
    initial_css = template.template_css or ""
    design_json = template.design_json or ""
    template_json = template.template_json or ""
    current_app.logger.info(
        f"[PDF_TEMPLATE] Quote template loaded for editor - PageSize: '{page_size}', HTML length: {len(initial_html)}, CSS length: {len(initial_css)}, DesignJSON length: {len(design_json)}, TemplateJSON length: {len(template_json)}, TemplateID: {template.id}"
    )

    # Load default template if empty
    try:
        if not initial_html:
            env = current_app.jinja_env
            html_src, _unused1, _unused2 = env.loader.get_source(env, "quotes/pdf_default.html")
            try:
                import re as _re

                m = _re.search(r"<body[^>]*>([\s\S]*?)</body>", html_src, _re.IGNORECASE)
                initial_html = m.group(1).strip() if m else html_src
            except Exception as e:
                safe_log(current_app.logger, "debug", "Quote PDF template body regex failed: %s", e)
        if not initial_css:
            env = current_app.jinja_env
            css_src, _unused3, _unused4 = env.loader.get_source(env, "quotes/pdf_styles_default.css")
            initial_css = css_src
    except Exception as e:
        safe_log(current_app.logger, "warning", "Quote PDF layout initialization failed: %s", e)

    # Normalize @page size in initial CSS to match the selected page size
    # This ensures the editor always shows the correct page size
    if initial_css:
        from app.utils.pdf_generator import update_page_size_in_css

        initial_css = update_page_size_in_css(initial_css, page_size)

    return render_template(
        "admin/quote_pdf_layout.html",
        settings=Settings.get_settings(),
        initial_html=initial_html,
        initial_css=initial_css,
        design_json=design_json,
        template_json=template_json,
        page_size=page_size,
        all_templates=all_templates,
        date_format=getattr(template, "date_format", None) or "%d.%m.%Y",
    )


@admin_bp.route("/admin/quote-pdf-layout/reset", methods=["POST"])
@limiter.limit("10 per minute")
@login_required
@admin_or_permission_required("manage_settings")
def quote_pdf_layout_reset():
    """Reset quote PDF layout to defaults (clear custom templates and regenerate default JSON)."""
    import json

    from app.models import QuotePDFTemplate
    from app.utils.pdf_template_schema import get_default_template

    # Get page size from query parameter or form, default to A4
    page_size = request.args.get("size", request.form.get("page_size", "A4"))

    # Ensure valid page size
    valid_sizes = ["A4", "Letter", "Legal", "A3", "A5", "Tabloid"]
    if page_size not in valid_sizes:
        page_size = "A4"

    # Get or create template for this page size
    template = QuotePDFTemplate.get_template(page_size)

    # Clear custom templates
    template.template_html = ""
    template.template_css = ""
    template.design_json = ""

    # Regenerate default JSON template
    default_json = get_default_template(page_size)
    template.template_json = json.dumps(default_json)
    template.updated_at = datetime.utcnow()

    if not safe_commit("admin_reset_quote_pdf_layout"):
        flash(_("Could not reset PDF layout due to a database error."), "error")
    else:
        flash(_("PDF layout reset to defaults"), "success")
    return redirect(url_for("admin.quote_pdf_layout", size=page_size))


@admin_bp.route("/admin/quote-pdf-layout/export-json/<page_size>", methods=["GET"])
@login_required
@admin_or_permission_required("manage_settings")
def quote_pdf_layout_export_json(page_size):
    """Export quote PDF template as JSON file."""
    from io import BytesIO

    from app.models import QuotePDFTemplate

    # Validate page size
    valid_sizes = ["A4", "Letter", "Legal", "A3", "A5", "Tabloid"]
    if page_size not in valid_sizes:
        flash(_("Invalid page size"), "error")
        return redirect(url_for("admin.quote_pdf_layout", size="A4"))

    # Get template
    template = QuotePDFTemplate.query.filter_by(page_size=page_size).first()
    if not template:
        flash(_("Template not found for this page size"), "error")
        return redirect(url_for("admin.quote_pdf_layout", size=page_size))

    # Get template JSON
    template_json = template.template_json or "{}"

    # Create file-like object
    output = BytesIO()
    output.write(template_json.encode("utf-8"))
    output.seek(0)

    # Return as downloadable file
    filename = f"quote_pdf_template_{page_size}.json"
    return send_file(output, mimetype="application/json", as_attachment=True, download_name=filename)


@admin_bp.route("/admin/quote-pdf-layout/import-json", methods=["POST"])
@limiter.limit("10 per minute")
@login_required
@admin_or_permission_required("manage_settings")
def quote_pdf_layout_import_json():
    """Import quote PDF template from JSON file."""
    import json

    from app.models import QuotePDFTemplate
    from app.utils.pdf_template_schema import get_page_dimensions_mm

    # Get page size from form or detect from JSON
    page_size = request.form.get("page_size", "A4")

    # Validate page size
    valid_sizes = ["A4", "Letter", "Legal", "A3", "A5", "Tabloid"]
    if page_size not in valid_sizes:
        page_size = "A4"

    # Check if file was uploaded
    if "json_file" not in request.files:
        flash(_("No file uploaded"), "error")
        return redirect(url_for("admin.quote_pdf_layout", size=page_size))

    file = request.files["json_file"]
    if file.filename == "":
        flash(_("No file selected"), "error")
        return redirect(url_for("admin.quote_pdf_layout", size=page_size))

    # Read and parse JSON
    try:
        file_content = file.read().decode("utf-8")
        template_json_dict = json.loads(file_content)

        # Validate JSON structure
        if not isinstance(template_json_dict, dict) or "page" not in template_json_dict:
            flash(_("Invalid template JSON format. Missing 'page' property."), "error")
            return redirect(url_for("admin.quote_pdf_layout", size=page_size))

        # Detect page size from JSON if not provided
        json_page_size = template_json_dict.get("page", {}).get("size")
        if json_page_size and json_page_size in valid_sizes:
            page_size = json_page_size

        # Update page size in JSON
        template_json_dict["page"]["size"] = page_size

        # Ensure page dimensions match
        expected_dims = get_page_dimensions_mm(page_size)
        template_page_config = template_json_dict.get("page", {})
        template_page_config["width"] = expected_dims["width"]
        template_page_config["height"] = expected_dims["height"]
        template_json_dict["page"] = template_page_config

        # Get or create template
        template = QuotePDFTemplate.get_template(page_size)

        # Update template JSON
        template.template_json = json.dumps(template_json_dict)
        template.updated_at = datetime.utcnow()

        if not safe_commit("admin_import_quote_pdf_layout_json"):
            flash(_("Could not import template due to a database error."), "error")
        else:
            flash(_("Template imported successfully"), "success")

        return redirect(url_for("admin.quote_pdf_layout", size=page_size))

    except json.JSONDecodeError as e:
        flash(_("Invalid JSON file: %(error)s", error=str(e)), "error")
        return redirect(url_for("admin.quote_pdf_layout", size=page_size))
    except Exception as e:
        current_app.logger.error(f"Error importing quote PDF template JSON: {e}", exc_info=True)
        flash(_("Error importing template: %(error)s", error=str(e)), "error")
        return redirect(url_for("admin.quote_pdf_layout", size=page_size))


@admin_bp.route("/admin/pdf-layout/export-json/<page_size>", methods=["GET"])
@login_required
@admin_or_permission_required("manage_settings")
def pdf_layout_export_json(page_size):
    """Export invoice PDF template as JSON file."""
    from io import BytesIO

    from app.models import InvoicePDFTemplate

    # Validate page size
    valid_sizes = ["A4", "Letter", "Legal", "A3", "A5", "Tabloid"]
    if page_size not in valid_sizes:
        flash(_("Invalid page size"), "error")
        return redirect(url_for("admin.pdf_layout", size="A4"))

    # Get template
    template = InvoicePDFTemplate.query.filter_by(page_size=page_size).first()
    if not template:
        flash(_("Template not found for this page size"), "error")
        return redirect(url_for("admin.pdf_layout", size=page_size))

    # Get template JSON
    template_json = template.template_json or "{}"

    # Create file-like object
    output = BytesIO()
    output.write(template_json.encode("utf-8"))
    output.seek(0)

    # Return as downloadable file
    filename = f"invoice_pdf_template_{page_size}.json"
    return send_file(output, mimetype="application/json", as_attachment=True, download_name=filename)


@admin_bp.route("/admin/pdf-layout/import-json", methods=["POST"])
@limiter.limit("10 per minute")
@login_required
@admin_or_permission_required("manage_settings")
def pdf_layout_import_json():
    """Import invoice PDF template from JSON file."""
    import json

    from app.models import InvoicePDFTemplate
    from app.utils.pdf_template_schema import get_page_dimensions_mm

    # Get page size from form or detect from JSON
    page_size = request.form.get("page_size", "A4")

    # Validate page size
    valid_sizes = ["A4", "Letter", "Legal", "A3", "A5", "Tabloid"]
    if page_size not in valid_sizes:
        page_size = "A4"

    # Check if file was uploaded
    if "json_file" not in request.files:
        flash(_("No file uploaded"), "error")
        return redirect(url_for("admin.pdf_layout", size=page_size))

    file = request.files["json_file"]
    if file.filename == "":
        flash(_("No file selected"), "error")
        return redirect(url_for("admin.pdf_layout", size=page_size))

    # Read and parse JSON
    try:
        file_content = file.read().decode("utf-8")
        template_json_dict = json.loads(file_content)

        # Validate JSON structure
        if not isinstance(template_json_dict, dict) or "page" not in template_json_dict:
            flash(_("Invalid template JSON format. Missing 'page' property."), "error")
            return redirect(url_for("admin.pdf_layout", size=page_size))

        # Detect page size from JSON if not provided
        json_page_size = template_json_dict.get("page", {}).get("size")
        if json_page_size and json_page_size in valid_sizes:
            page_size = json_page_size

        # Update page size in JSON
        template_json_dict["page"]["size"] = page_size

        # Ensure page dimensions match
        expected_dims = get_page_dimensions_mm(page_size)
        template_page_config = template_json_dict.get("page", {})
        template_page_config["width"] = expected_dims["width"]
        template_page_config["height"] = expected_dims["height"]
        template_json_dict["page"] = template_page_config

        # Get or create template
        template = InvoicePDFTemplate.get_template(page_size)

        # Update template JSON
        template.template_json = json.dumps(template_json_dict)
        template.updated_at = datetime.utcnow()

        if not safe_commit("admin_import_pdf_layout_json"):
            flash(_("Could not import template due to a database error."), "error")
        else:
            flash(_("Template imported successfully"), "success")

        return redirect(url_for("admin.pdf_layout", size=page_size))

    except json.JSONDecodeError as e:
        flash(_("Invalid JSON file: %(error)s", error=str(e)), "error")
        return redirect(url_for("admin.pdf_layout", size=page_size))
    except Exception as e:
        current_app.logger.error(f"Error importing invoice PDF template JSON: {e}", exc_info=True)
        flash(_("Error importing template: %(error)s", error=str(e)), "error")
        return redirect(url_for("admin.pdf_layout", size=page_size))


@admin_bp.route("/admin/pdf-layout/debug", methods=["GET"])
@login_required
@admin_or_permission_required("manage_settings")
def pdf_layout_debug():
    """Debug endpoint to show what's saved in the database"""
    settings_obj = Settings.get_settings()

    html = settings_obj.invoice_pdf_template_html or ""
    css = settings_obj.invoice_pdf_template_css or ""
    design_json = settings_obj.invoice_pdf_design_json or ""

    # Check for bugs
    has_all_bug = "invoice.items.all()" in html
    has_if_bug = "invoice.items and invoice.items.all()" in html

    # Get invoice info for testing
    from app.models import Invoice

    test_invoice = Invoice.query.order_by(Invoice.id.desc()).first()

    debug_info = {
        "saved_template": {
            "html_length": len(html),
            "css_length": len(css),
            "design_json_length": len(design_json),
            "has_html": bool(html),
            "has_bugs": has_all_bug or has_if_bug,
            "bugs_found": [],
        },
        "test_invoice": {
            "exists": test_invoice is not None,
            "invoice_number": test_invoice.invoice_number if test_invoice else None,
            "items_count": test_invoice.items.count() if test_invoice else 0,
        },
    }

    if has_all_bug:
        debug_info["saved_template"]["bugs_found"].append("invoice.items.all() found in template")
    if has_if_bug:
        debug_info["saved_template"]["bugs_found"].append("invoice.items and invoice.items.all() found in template")

    # Show snippets of problematic code
    if has_all_bug or has_if_bug:
        import re

        matches = re.finditer(r".{0,50}invoice\.items\.all\(\).{0,50}", html)
        debug_info["saved_template"]["bug_snippets"] = [m.group() for m in matches]

    return jsonify(debug_info)


@admin_bp.route("/admin/pdf-layout/default", methods=["GET"])
@login_required
@admin_or_permission_required("manage_settings")
def pdf_layout_default():
    """Return default HTML and CSS template sources for the PDF layout editor."""
    try:
        env = current_app.jinja_env
        # Get raw template sources, not rendered
        html_src, _, _ = env.loader.get_source(env, "invoices/pdf_default.html")
        # Extract only the body content for GrapesJS
        try:
            import re as _re

            match = _re.search(r"<body[^>]*>([\s\S]*?)</body>", html_src, _re.IGNORECASE)
            if match:
                html_src = match.group(1).strip()
        except Exception as e:
            safe_log(current_app.logger, "debug", "Invoice PDF template body regex failed: %s", e)
    except Exception as e:
        safe_log(current_app.logger, "warning", "Invoice PDF layout initialization failed: %s", e)
        html_src = "<div class=\"wrapper\"><h1>{{ _('INVOICE') }} {{ invoice.invoice_number }}</h1></div>"
    try:
        css_src, _, _ = env.loader.get_source(env, "invoices/pdf_styles_default.css")
    except Exception as e:
        safe_log(current_app.logger, "debug", "Invoice PDF default CSS load failed: %s", e)
        css_src = ""
    return jsonify(
        {
            "html": html_src,
            "css": css_src,
        }
    )


@admin_bp.route("/admin/pdf-layout/preview", methods=["POST"])
@limiter.limit("60 per minute")
@login_required
@admin_or_permission_required("manage_settings")
def pdf_layout_preview():
    """Render a live preview of the provided HTML/CSS or JSON template using an invoice context."""
    html = request.form.get("html", "")
    css = request.form.get("css", "")
    template_json_str = request.form.get("template_json", "")  # JSON template from editor
    page_size_raw = request.form.get("page_size", "A4")  # Get page size from form
    invoice_id = request.form.get("invoice_id", type=int)

    current_app.logger.info(
        f"[PDF_PREVIEW] Action: invoice_preview_request, PageSize: '{page_size_raw}', HTML length: {len(html)}, CSS length: {len(css)}, TemplateJSON length: {len(template_json_str)}, InvoiceID: {invoice_id}, User: {current_user.username}"
    )

    # Validate page size
    valid_sizes = ["A4", "Letter", "Legal", "A3", "A5", "Tabloid"]
    if page_size_raw not in valid_sizes:
        current_app.logger.warning(
            f"[PDF_PREVIEW] Invalid page size '{page_size_raw}', defaulting to A4, User: {current_user.username}"
        )
        page_size = "A4"
    else:
        page_size = page_size_raw

    current_app.logger.info(
        f"[PDF_PREVIEW] Final validated PageSize: '{page_size}', TemplateJSON provided: {bool(template_json_str and template_json_str.strip())}"
    )

    # Prefer form template_json (current canvas, including unsaved edits); fall back to saved DB template
    import json

    from app.models import InvoicePDFTemplate
    from app.utils.pdf_template_schema import get_page_dimensions_mm

    template_json_parsed = None
    saved_template = InvoicePDFTemplate.query.filter_by(page_size=page_size).first()

    if template_json_str and template_json_str.strip():
        try:
            current_app.logger.info(
                f"[PDF_PREVIEW] Parsing form-provided JSON template (preferred for live preview) - PageSize: '{page_size}', JSON length: {len(template_json_str)}"
            )
            template_json_parsed = json.loads(template_json_str)
            if isinstance(template_json_parsed, dict):
                template_json_parsed.setdefault("page", {})
                template_json_parsed["page"]["size"] = page_size
                expected_dims = get_page_dimensions_mm(page_size)
                template_json_parsed["page"]["width"] = expected_dims["width"]
                template_json_parsed["page"]["height"] = expected_dims["height"]
            element_count = (
                len(template_json_parsed.get("elements", [])) if isinstance(template_json_parsed, dict) else 0
            )
            json_page_size = (
                template_json_parsed.get("page", {}).get("size", "unknown")
                if isinstance(template_json_parsed, dict)
                else "unknown"
            )
            current_app.logger.info(
                f"[PDF_PREVIEW] Form JSON template parsed and page normalized - PageSize: '{page_size}', JSON PageSize: '{json_page_size}', Elements: {element_count}"
            )
        except json.JSONDecodeError as e:
            current_app.logger.warning(
                f"[PDF_PREVIEW] Invalid form template_json, will try database - PageSize: '{page_size}', Error: {str(e)}, User: {current_user.username}"
            )
            template_json_parsed = None

    if template_json_parsed is None and saved_template and saved_template.template_json and saved_template.template_json.strip():
        try:
            current_app.logger.info(
                f"[PDF_PREVIEW] Loading saved template JSON from database (fallback) - PageSize: '{page_size}', TemplateID: {saved_template.id}, JSON length: {len(saved_template.template_json)}"
            )
            template_json_parsed = json.loads(saved_template.template_json)
            element_count = len(template_json_parsed.get("elements", []))
            json_page_size = template_json_parsed.get("page", {}).get("size", "unknown")
            current_app.logger.info(
                f"[PDF_PREVIEW] Saved template JSON loaded - PageSize: '{page_size}', JSON PageSize: '{json_page_size}', Elements: {element_count}"
            )
        except json.JSONDecodeError as e:
            current_app.logger.error(
                f"[PDF_PREVIEW] Failed to parse saved template JSON - PageSize: '{page_size}', Error: {str(e)}, User: {current_user.username}",
                exc_info=True,
            )
            template_json_parsed = None

    invoice = None
    if invoice_id:
        invoice = Invoice.query.get(invoice_id)
        if invoice is None:
            flash(_("Invoice not found"), "error")
            return redirect(url_for("admin.settings"))
    if invoice is None:
        invoice = Invoice.query.order_by(Invoice.id.desc()).first()
    settings_obj = Settings.get_settings()

    # Provide a minimal mock invoice if none exists to avoid template errors
    from types import SimpleNamespace

    if invoice is None:
        from datetime import date

        invoice = SimpleNamespace(
            id=None,
            invoice_number="0000",
            issue_date=date.today(),
            due_date=date.today(),
            status="draft",
            client_name="Sample Client",
            client_email="",
            client_address="",
            client=SimpleNamespace(name="Sample Client", email="", address=""),
            project=SimpleNamespace(name="Sample Project", description=""),
            items=[],
            extra_goods=[],
            subtotal=0.0,
            tax_rate=0.0,
            tax_amount=0.0,
            total_amount=0.0,
            notes="",
            terms="",
        )
    # Ensure at least one sample item to avoid undefined 'item' in templates that reference it outside loops
    sample_item = SimpleNamespace(
        description="Sample item", quantity=1.0, unit_price=0.0, total_amount=0.0, time_entry_ids=""
    )

    # Create a wrapper object with converted Query objects to lists
    # We can't modify SQLAlchemy model attributes directly, so we create a wrapper
    invoice_wrapper = SimpleNamespace()

    # Copy all simple attributes from the invoice
    for attr in [
        "id",
        "invoice_number",
        "project_id",
        "client_name",
        "client_email",
        "client_address",
        "client_id",
        "issue_date",
        "due_date",
        "status",
        "subtotal",
        "tax_rate",
        "tax_amount",
        "total_amount",
        "currency_code",
        "notes",
        "terms",
        "payment_date",
        "payment_method",
        "payment_reference",
        "payment_notes",
        "amount_paid",
        "payment_status",
        "created_by",
        "created_at",
        "updated_at",
    ]:
        try:
            setattr(invoice_wrapper, attr, getattr(invoice, attr))
        except AttributeError:
            pass

    # Copy relationship attributes (project, client)
    _invoice_id = getattr(invoice, "id", None)
    try:
        invoice_wrapper.project = invoice.project
    except (AttributeError, RuntimeError) as e:
        current_app.logger.debug(f"Could not access invoice.project for invoice {_invoice_id}: {e}")
        invoice_wrapper.project = SimpleNamespace(name="Sample Project", description="")

    try:
        invoice_wrapper.client = getattr(invoice, "client", None)
    except (AttributeError, RuntimeError) as e:
        current_app.logger.debug(f"Could not access invoice.client for invoice {_invoice_id}: {e}")
        invoice_wrapper.client = None

    # Convert items from Query to list
    try:
        if hasattr(invoice, "items") and hasattr(invoice.items, "all"):
            # It's a SQLAlchemy Query object - call .all() to get list
            items_list = invoice.items.all()
            if not items_list:
                # No items in database, add sample
                items_list = [sample_item]
            invoice_wrapper.items = items_list
        elif hasattr(invoice, "items") and isinstance(invoice.items, list):
            # Already a list
            invoice_wrapper.items = invoice.items if invoice.items else [sample_item]
        else:
            # Fallback
            invoice_wrapper.items = [sample_item]
    except Exception as e:
        print(f"Error converting invoice items: {e}")
        invoice_wrapper.items = [sample_item]

    # Convert extra_goods from Query to list
    try:
        if hasattr(invoice, "extra_goods") and hasattr(invoice.extra_goods, "all"):
            invoice_wrapper.extra_goods = invoice.extra_goods.all()
        elif hasattr(invoice, "extra_goods") and isinstance(invoice.extra_goods, list):
            invoice_wrapper.extra_goods = invoice.extra_goods
        else:
            invoice_wrapper.extra_goods = []
    except Exception:
        invoice_wrapper.extra_goods = []

    # Convert expenses from Query to list
    try:
        if hasattr(invoice, "expenses") and hasattr(invoice.expenses, "all"):
            invoice_wrapper.expenses = invoice.expenses.all()
        elif hasattr(invoice, "expenses") and isinstance(invoice.expenses, list):
            invoice_wrapper.expenses = invoice.expenses
        else:
            invoice_wrapper.expenses = []
    except Exception:
        invoice_wrapper.expenses = []

    # Build combined all_line_items for preview (items + extra_goods + expenses) to match PDF export
    all_line_items = []
    for item in invoice_wrapper.items:
        all_line_items.append(
            SimpleNamespace(
                description=getattr(item, "description", str(item)) or "",
                quantity=getattr(item, "quantity", 1),
                unit_price=getattr(item, "unit_price", 0),
                total_amount=getattr(item, "total_amount", 0),
            )
        )
    for good in invoice_wrapper.extra_goods:
        desc_parts = [getattr(good, "name", str(good)) or ""]
        if getattr(good, "description", None):
            desc_parts.append(str(good.description))
        if getattr(good, "sku", None):
            desc_parts.append(f"SKU: {good.sku}")
        if getattr(good, "category", None):
            desc_parts.append(f"Category: {good.category.title()}")
        all_line_items.append(
            SimpleNamespace(
                description="\n".join(desc_parts),
                quantity=getattr(good, "quantity", 1),
                unit_price=getattr(good, "unit_price", 0),
                total_amount=getattr(good, "total_amount", 0),
            )
        )
    for expense in invoice_wrapper.expenses:
        desc_parts = [getattr(expense, "title", str(expense)) or ""]
        if getattr(expense, "description", None):
            desc_parts.append(str(expense.description))
        amt = getattr(expense, "total_amount", None) or getattr(expense, "amount", 0)
        all_line_items.append(
            SimpleNamespace(
                description="\n".join(desc_parts),
                quantity=1,
                unit_price=amt,
                total_amount=amt,
            )
        )
    invoice_wrapper.all_line_items = all_line_items

    # Use the wrapper instead of the original invoice
    invoice = invoice_wrapper

    # CRITICAL: Always use template_json for preview - convert to HTML/CSS with actual invoice data
    if template_json_parsed:
        try:
            # Convert JSON template to HTML/CSS with actual invoice data for better table rendering
            html, css = _convert_json_template_to_html_css(
                template_json_parsed, page_size, invoice=invoice, quote=None, settings=settings_obj
            )
            items_count = len(invoice.items) if hasattr(invoice, "items") and invoice.items else 0
            current_app.logger.info(
                f"[PDF_PREVIEW] JSON template converted with invoice data - PageSize: '{page_size}', HTML length: {len(html)}, CSS length: {len(css)}, Items count: {items_count}"
            )
        except Exception as e:
            current_app.logger.error(
                f"[PDF_PREVIEW] Failed to convert JSON template with invoice data - PageSize: '{page_size}', Error: {str(e)}",
                exc_info=True,
            )
            # Fall back to empty HTML/CSS
            html = "<div class='invoice-wrapper'></div>"
            css = ""
    else:
        # No template_json in form or database (or both failed to parse)
        current_app.logger.error(
            f"[PDF_PREVIEW] No template JSON available for preview - PageSize: '{page_size}', SavedTemplateExists: {saved_template is not None}, SavedTemplateHasJSON: {bool(saved_template and saved_template.template_json and saved_template.template_json.strip())}, FormTemplateProvided: {bool(template_json_str and template_json_str.strip())}, User: {current_user.username}"
        )
        html = "<div class='invoice-wrapper'><p style='color:red; padding:20px;'>Error: No template found. Add content in the editor or save a template first.</p></div>"
        css = ""

    # CRITICAL: Load the saved template CSS for this page size and merge with editor CSS
    # The editor generates minimal CSS, but we need the full template CSS for proper preview
    import re

    from app.utils.pdf_generator import update_page_size_in_css, validate_page_size_in_css

    saved_css = None  # Initialize saved_css to avoid UnboundLocalError
    if saved_template:
        current_app.logger.info(
            f"[PDF_PREVIEW] Retrieved saved invoice template - PageSize: '{page_size}', TemplateID: {saved_template.id}, HasCSS: {bool(saved_template.template_css)}"
        )
        if saved_template.template_css and saved_template.template_css.strip():
            # Use the saved template CSS as base, but normalize it first to ensure correct @page size
            saved_css = saved_template.template_css
            # CRITICAL: Normalize the saved template CSS to ensure it has the correct @page size
            saved_css = update_page_size_in_css(saved_css, page_size)
            current_app.logger.info(
                f"[PDF_PREVIEW] Using saved invoice template CSS - PageSize: '{page_size}', CSS length: {len(saved_css)}, TemplateID: {saved_template.id}"
            )

        # If editor provided CSS, merge it (editor CSS takes precedence for @page rules)
        if css and css.strip():
            # Extract @page rule from editor CSS if present
            editor_page_match = re.search(r"@page\s*\{[^}]*\}", css, re.IGNORECASE | re.DOTALL)
            if editor_page_match:
                # Editor has @page rule - normalize it and use it, merge with saved CSS
                editor_page_rule = editor_page_match.group(0)
                # Normalize editor's @page rule to correct size FIRST
                editor_page_rule = update_page_size_in_css(editor_page_rule, page_size)
                # Remove @page from saved CSS and add normalized editor's @page rule
                if saved_css:
                    saved_css_no_page = re.sub(r"@page\s*\{[^}]*\}", "", saved_css, flags=re.IGNORECASE | re.DOTALL)
                else:
                    saved_css_no_page = ""
                # Remove @page rule from editor CSS and merge
                editor_css_no_page = css.replace(editor_page_rule, "").strip()
                css = editor_page_rule + "\n" + saved_css_no_page
                if editor_css_no_page:
                    css = css + "\n" + editor_css_no_page
            else:
                # No @page in editor CSS, use saved CSS (already normalized) and add editor CSS
                if saved_css:
                    css = saved_css + "\n" + css
                # else: css already has the editor CSS, no need to merge
        else:
            # No editor CSS, use saved template CSS (already normalized) if available
            if saved_css:
                css = saved_css
    elif not css or not css.strip():
        # No template CSS and no editor CSS - create default with correct page size
        css = f"@page {{\n    size: {page_size};\n    margin: 2cm;\n}}\n"

    # Normalize @page size in CSS to match the selected page size
    # This ensures preview matches what will be exported
    if css:
        # Always normalize @page size to ensure it matches the selected page size
        css_before = css
        css = update_page_size_in_css(css, page_size)

        # Log if normalization changed anything
        if css != css_before:
            current_app.logger.debug(f"PDF Preview - CSS @page size normalized from template/editor to {page_size}")

        # Validate after normalization
        is_valid, found_sizes = validate_page_size_in_css(css, page_size)
        if not is_valid:
            current_app.logger.warning(
                f"Invoice PDF preview CSS @page size normalization failed for {page_size}. "
                f"Found sizes: {found_sizes}. Forcing correct size."
            )
            # Force add @page rule if validation failed
            if "@page" not in css:
                css = f"@page {{\n    size: {page_size};\n    margin: 2cm;\n}}\n\n" + css
            else:
                # Try to fix it by replacing any existing @page size
                # Use a more robust regex that handles quotes and whitespace
                css = re.sub(
                    r"size\s*:\s*['\"]?[^;}\n]+['\"]?", f"size: {page_size}", css, flags=re.IGNORECASE | re.MULTILINE
                )
    else:
        # No CSS provided, add default @page rule
        css = update_page_size_in_css("", page_size)

    # Final validation and logging
    is_valid, found_sizes = validate_page_size_in_css(css, page_size)
    if is_valid:
        current_app.logger.info(
            f"[PDF_PREVIEW] CSS validated successfully - PageSize: '{page_size}', Final CSS length: {len(css)}, Final HTML length: {len(html)}"
        )
    else:
        current_app.logger.error(
            f"[PDF_PREVIEW] CSS validation FAILED - PageSize: '{page_size}', Found sizes: {found_sizes}, User: {current_user.username}"
        )

    # Helper: remove @page rules from HTML inline styles when separate CSS exists
    # This matches the fix used in PDF exports to avoid conflicts with WeasyPrint
    def remove_page_rule_from_html(html_text):
        """Remove @page rules from HTML inline styles to avoid conflicts with separate CSS"""
        import re

        def remove_from_style_tag(match):
            style_content = match.group(2)
            # Remove @page rule from style content
            # Need to handle nested @bottom-center rules properly
            # Match @page { ... } including any nested rules
            brace_count = 0
            page_pattern = r"@page\s*\{"
            page_match = re.search(page_pattern, style_content, re.IGNORECASE)

            if page_match:
                start = page_match.start()
                # Find matching closing brace
                end = len(style_content)
                for i in range(page_match.end() - 1, len(style_content)):
                    if style_content[i] == "{":
                        brace_count += 1
                    elif style_content[i] == "}":
                        brace_count -= 1
                        if brace_count == 0:
                            end = i + 1
                            break
                # Remove the @page rule
                style_content = style_content[:start] + style_content[end:]
                # Clean up any double newlines or extra whitespace
                style_content = re.sub(r"\n\s*\n", "\n", style_content)

            return f"{match.group(1)}{style_content}{match.group(3)}"

        # Match <style> tags and remove @page rules from them
        style_pattern = r"(<style[^>]*>)(.*?)(</style>)"
        if re.search(style_pattern, html_text, re.IGNORECASE | re.DOTALL):
            html_text = re.sub(style_pattern, remove_from_style_tag, html_text, flags=re.IGNORECASE | re.DOTALL)

        return html_text

    # Apply @page rule removal fix: if we have separate CSS and HTML with inline styles,
    # remove @page rules from HTML to ensure the separate CSS @page rule is used
    html_has_inline_styles = html and "<style>" in html
    if html_has_inline_styles and css and css.strip():
        # Check if HTML has @page rules
        import re

        html_page_rules = re.findall(r"@page\s*\{[^}]*\}", html, re.IGNORECASE | re.DOTALL)
        if html_page_rules:
            current_app.logger.debug(
                f"PDF preview: Found {len(html_page_rules)} @page rule(s) in HTML inline styles - removing them"
            )
            # Remove @page rules from HTML inline styles (keep everything else)
            html = remove_page_rule_from_html(html)
            current_app.logger.debug("PDF preview: Removed @page rules from HTML inline styles")

    # Helper: sanitize Jinja blocks to fix entities/smart quotes inserted by editor
    def _sanitize_jinja_blocks(raw: str) -> str:
        try:
            import html as _html
            import re as _re

            smart_map = {
                "\u201c": '"',
                "\u201d": '"',  # “ ” -> "
                "\u2018": "'",
                "\u2019": "'",  # ‘ ’ -> '
                "\u00a0": " ",  # nbsp
                "\u200b": "",
                "\u200c": "",
                "\u200d": "",  # zero-width
            }

            def _fix_quotes(s: str) -> str:
                for k, v in smart_map.items():
                    s = s.replace(k, v)
                return s

            def _clean(match):
                open_tag = match.group(1)
                inner = match.group(2)
                # Remove any HTML tags GrapesJS may have inserted inside Jinja braces
                inner = _re.sub(r"</?[^>]+?>", "", inner)
                # Decode HTML entities
                inner = _html.unescape(inner)
                # Fix smart quotes and nbsp
                inner = _fix_quotes(inner)
                # Trim excessive whitespace around pipes and parentheses
                inner = _re.sub(r"\s+\|\s+", " | ", inner)
                inner = _re.sub(r"\(\s+", "(", inner)
                inner = _re.sub(r"\s+\)", ")", inner)
                # Normalize _("...") -> _('...')
                inner = inner.replace('_("', "_('").replace('")', "')")
                return f"{open_tag}{inner}{' }}' if open_tag == '{{ ' else ' %}'}"

            pattern = _re.compile(r"({{\s|{%\s)([\s\S]*?)(?:}}|%})")
            return _re.sub(pattern, _clean, raw)
        except Exception:
            return raw

    sanitized = _sanitize_jinja_blocks(html)

    # Wrap provided HTML with a minimal page and CSS
    try:
        from pathlib import Path as _Path

        # Provide helpers as callables since templates may use function-style helpers
        try:
            from babel.dates import format_date as _babel_format_date
        except Exception:
            _babel_format_date = None

        def _format_date(value, format="medium"):
            try:
                # Use DD.MM.YYYY format for invoices and quotes
                return value.strftime("%d.%m.%Y") if value else ""
            except Exception:
                return str(value) if value else ""

        def _format_money(value):
            try:
                return f"{float(value):,.2f} {settings_obj.currency}"
            except Exception:
                return f"{value} {settings_obj.currency}"

        # Helper function for logo - converts to base64 data URI
        def _get_logo_base64(logo_path):
            try:
                if not logo_path or not os.path.exists(logo_path):
                    return None
                import base64
                import mimetypes

                with open(logo_path, "rb") as f:
                    data = base64.b64encode(f.read()).decode("utf-8")
                mime_type, _ = mimetypes.guess_type(logo_path)
                if not mime_type:
                    mime_type = "image/png"
                return f"data:{mime_type};base64,{data}"
            except Exception as e:
                print(f"Error loading logo: {e}")
                return None

        current_app.logger.info(
            f"[PDF_PREVIEW] Rendering template string - PageSize: '{page_size}', InvoiceID: {invoice_id}, Sanitized HTML length: {len(sanitized)}"
        )
        body_html = render_sandboxed_string(
            sanitized,
            autoescape=True,
            invoice=invoice,
            settings=settings_obj,
            Path=_Path,
            format_date=_format_date,
            format_money=_format_money,
            get_logo_base64=_get_logo_base64,
            item=sample_item,
        )
        current_app.logger.info(
            f"[PDF_PREVIEW] Template rendered successfully - PageSize: '{page_size}', Rendered HTML length: {len(body_html)}"
        )
    except Exception as e:
        import traceback

        error_details = traceback.format_exc()
        current_app.logger.error(
            f"[PDF_PREVIEW] Template render error - PageSize: '{page_size}', Error: {str(e)}, User: {current_user.username}",
            exc_info=True,
        )
        body_html = (
            f"<div style='color:red; padding:20px; border:2px solid red; margin:20px;'><h3>Template error:</h3><pre>{str(e)}</pre><pre>{error_details}</pre></div>"
            + sanitized
        )
    # Get page dimensions for preview styling
    page_dimensions = InvoicePDFTemplate.PAGE_SIZES.get(page_size, InvoicePDFTemplate.PAGE_SIZES["A4"])
    page_width_mm = page_dimensions["width"]
    page_height_mm = page_dimensions["height"]
    # Convert mm to pixels at 96 DPI (standard browser DPI for PDF preview)
    # 1 inch = 25.4mm, 96 DPI = 96 pixels per inch
    # Account for margins (typically 20mm = ~75px at 96 DPI)
    margin_px = int((20 / 25.4) * 96)  # 20mm margin in pixels
    # Don't subtract margins from page dimensions - margins are applied to content, not page size
    # Calculate full page dimensions at 96 DPI for browser preview
    page_width_px = int((page_width_mm / 25.4) * 96)
    page_height_px = int((page_height_mm / 25.4) * 96)

    # Build complete HTML page with embedded styles
    # Build complete HTML page with embedded styles
    # For preview, scale to fit viewport while maintaining aspect ratio
    page_html = f"""<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Invoice Preview ({page_size})</title>
    <style>{css}
/* Preview-specific styles - completely new approach */
* {{
    box-sizing: border-box;
    margin: 0;
    padding: 0;
}}
html {{
    width: 100% !important;
    margin: 0 !important;
    padding: 0 !important;
    overflow: auto !important;
    overflow-x: auto !important;
    overflow-y: auto !important;
    background-color: #e5e7eb !important;
}}
body {{
    width: 100% !important;
    min-width: 100% !important;
    margin: 0 !important;
    padding: 20px !important;
    padding-top: 20px !important;
    overflow: auto !important;
    overflow-x: auto !important;
    overflow-y: auto !important;
    background-color: #e5e7eb !important;
    display: flex !important;
    align-items: flex-start !important;
    justify-content: center !important;
    box-sizing: border-box !important;
    min-height: 100vh !important;
}}
.preview-container {{
    background: white;
    box-shadow: 0 4px 20px rgba(0,0,0,0.15);
    border-radius: 8px;
    overflow: visible;
    position: relative;
    width: {page_width_px}px;
    min-width: {page_width_px}px;
    max-width: {page_width_px}px;
    display: flex;
    align-items: flex-start;
    justify-content: center;
    transition: transform 0.2s ease;
    transform-origin: top center;
    margin-top: 20px;
}}
.preview-container .invoice-wrapper,
.preview-container .quote-wrapper {{
    width: {page_width_px}px !important;
    min-width: {page_width_px}px !important;
    max-width: {page_width_px}px !important;
    height: {page_height_px}px !important;
    min-height: {page_height_px}px !important;
    max-height: {page_height_px}px !important;
    box-sizing: border-box !important;
    overflow: visible !important;
    margin: 0 auto !important;
    padding: 0 !important;
    background: transparent !important;
    position: relative;
    /* CSS zoom on container will scale this proportionally */
}}
</style>
<!-- Zoom is now controlled by the parent iframe's JavaScript -->
<script>
// Minimal script - zoom is handled by parent window
(function() {{
    // Ensure container maintains correct dimensions
    const container = document.querySelector('.preview-container');
    if (container) {{
        container.style.width = {page_width_px} + 'px';
        container.style.minWidth = {page_width_px} + 'px';
        container.style.maxWidth = {page_width_px} + 'px';
    }}
}})();
</script>
</head>
<body>
<div class="preview-container">
{body_html}
</div>
</body>
</html>"""
    current_app.logger.info(
        f"[PDF_PREVIEW] Returning invoice preview HTML - PageSize: '{page_size}', Total HTML length: {len(page_html)}, PageWidth: {page_width_px}px, PageHeight: {page_height_px}px, User: {current_user.username}"
    )
    return page_html


@admin_bp.route("/admin/quote-pdf-layout/preview", methods=["POST"])
@limiter.limit("60 per minute")
@login_required
@admin_or_permission_required("manage_settings")
def quote_pdf_layout_preview():
    """Render a live preview of the provided HTML/CSS or JSON template using a quote context."""
    # Extract and validate page_size FIRST, before any other processing
    page_size_raw = request.form.get("page_size", "A4")
    html = request.form.get("html", "")
    css = request.form.get("css", "")
    template_json_str = request.form.get("template_json", "")  # JSON template from editor
    quote_id = request.form.get("quote_id", type=int)

    current_app.logger.info(
        f"[PDF_PREVIEW] Action: quote_preview_request, PageSize: '{page_size_raw}', HTML length: {len(html)}, CSS length: {len(css)}, TemplateJSON length: {len(template_json_str)}, QuoteID: {quote_id}, User: {current_user.username}"
    )

    valid_sizes = ["A4", "Letter", "Legal", "A3", "A5", "Tabloid"]
    if page_size_raw not in valid_sizes:
        current_app.logger.warning(
            f"[PDF_PREVIEW] Invalid page size '{page_size_raw}', defaulting to A4, User: {current_user.username}"
        )
        page_size = "A4"
    else:
        page_size = page_size_raw

    current_app.logger.info(
        f"[PDF_PREVIEW] Final validated PageSize: '{page_size}', TemplateJSON provided: {bool(template_json_str and template_json_str.strip())}"
    )

    # Store template_json for later conversion with quote data
    template_json_parsed = None
    if template_json_str and template_json_str.strip():
        import json

        try:
            current_app.logger.info(
                f"[PDF_PREVIEW] Parsing quote JSON template - PageSize: '{page_size}', JSON length: {len(template_json_str)}"
            )
            template_json_parsed = json.loads(template_json_str)
            element_count = len(template_json_parsed.get("elements", []))
            json_page_size = template_json_parsed.get("page", {}).get("size", "unknown")
            current_app.logger.info(
                f"[PDF_PREVIEW] Quote JSON template parsed - PageSize: '{page_size}', JSON PageSize: '{json_page_size}', Elements: {element_count}"
            )
            # Will convert to HTML/CSS after quote data is loaded below
        except json.JSONDecodeError as e:
            current_app.logger.warning(
                f"[PDF_PREVIEW] Invalid quote template_json, falling back to HTML/CSS - PageSize: '{page_size}', Error: {str(e)}, User: {current_user.username}"
            )
            template_json_parsed = None
            # Fall through to use provided HTML/CSS
    quote = None
    if quote_id:
        quote = Quote.query.get(quote_id)
        if quote is None:
            flash(_("Quote not found"), "error")
            return redirect(url_for("admin.settings"))
    if quote is None:
        quote = Quote.query.order_by(Quote.id.desc()).first()
    settings_obj = Settings.get_settings()

    # Provide a minimal mock quote if none exists to avoid template errors
    from types import SimpleNamespace

    if quote is None:
        from datetime import date, datetime

        quote = SimpleNamespace(
            id=1,
            quote_number="Q-0001",
            title="Sample Quote",
            description="Sample quote description",
            status="draft",
            client_id=1,
            client=SimpleNamespace(
                name="Sample Client",
                email="client@example.com",
                address="123 Sample Street\nSample City, ST 12345",
                phone="+1 234 567 8900",
            ),
            project_id=None,
            project=None,
            items=[],
            subtotal=0.0,
            discount_type=None,
            discount_amount=0.0,
            discount_reason=None,
            coupon_code=None,
            tax_rate=0.0,
            tax_amount=0.0,
            total_amount=0.0,
            currency_code="EUR",
            valid_until=date.today(),
            sent_at=None,
            accepted_at=None,
            notes="",
            terms="",
            payment_terms=None,
            created_at=datetime.now(),
            updated_at=datetime.now(),
            created_by=1,
        )
    # Ensure at least one sample item to avoid undefined 'item' in templates that reference it outside loops
    sample_item = SimpleNamespace(description="Sample item", quantity=1.0, unit_price=0.0, total_amount=0.0)

    # Create a wrapper object with converted Query objects to lists
    quote_wrapper = SimpleNamespace()

    # Copy all simple attributes from the quote
    for attr in [
        "id",
        "quote_number",
        "title",
        "description",
        "status",
        "client_id",
        "project_id",
        "subtotal",
        "discount_type",
        "discount_amount",
        "discount_reason",
        "coupon_code",
        "tax_rate",
        "tax_amount",
        "total_amount",
        "currency_code",
        "valid_until",
        "sent_at",
        "accepted_at",
        "notes",
        "terms",
        "payment_terms",
        "created_at",
        "updated_at",
        "created_by",
    ]:
        try:
            setattr(quote_wrapper, attr, getattr(quote, attr))
        except AttributeError:
            pass

    # Copy relationship attributes (project, client)
    try:
        quote_wrapper.project = quote.project
    except (AttributeError, RuntimeError) as e:
        current_app.logger.debug(f"Could not access quote.project for quote {quote.id}: {e}")
        quote_wrapper.project = None

    try:
        quote_wrapper.client = quote.client
    except (AttributeError, RuntimeError) as e:
        current_app.logger.debug(f"Could not access quote.client for quote {quote.id}: {e}")
        quote_wrapper.client = SimpleNamespace(
            name="Sample Client",
            email="client@example.com",
            address="123 Sample Street\nSample City, ST 12345",
            phone="+1 234 567 8900",
        )

    # Convert items from Query to list
    try:
        if hasattr(quote, "items") and hasattr(quote.items, "all"):
            # It's a SQLAlchemy Query object - call .all() to get list
            items_list = quote.items.all()
            if not items_list:
                # No items in database, add sample
                items_list = [sample_item]
            quote_wrapper.items = items_list
        elif hasattr(quote, "items") and isinstance(quote.items, list):
            # Already a list
            quote_wrapper.items = quote.items if quote.items else [sample_item]
        else:
            # Fallback
            quote_wrapper.items = [sample_item]
    except Exception as e:
        print(f"Error converting quote items: {e}")
        quote_wrapper.items = [sample_item]

    # Use the wrapper instead of the original quote
    quote = quote_wrapper

    # If we have template_json, convert it to HTML/CSS for preview with actual quote data
    if template_json_parsed:
        try:
            # Convert JSON template to HTML/CSS with actual quote data for better table rendering
            html, css = _convert_json_template_to_html_css(
                template_json_parsed, page_size, invoice=None, quote=quote, settings=settings_obj
            )
            items_count = len(quote.items) if hasattr(quote, "items") and quote.items else 0
            current_app.logger.info(
                f"[PDF_PREVIEW] Quote JSON template converted with quote data - PageSize: '{page_size}', HTML length: {len(html)}, CSS length: {len(css)}, Items count: {items_count}"
            )
        except Exception as e:
            current_app.logger.error(
                f"[PDF_PREVIEW] Failed to convert quote JSON template with quote data - PageSize: '{page_size}', Error: {str(e)}",
                exc_info=True,
            )
            # Fall back to empty HTML/CSS
            html = "<div class='quote-wrapper'></div>"
            css = ""

    # CRITICAL: Load the saved template CSS for this page size and merge with editor CSS
    # The editor generates minimal CSS, but we need the full template CSS for proper preview
    import re

    from app.models import QuotePDFTemplate
    from app.utils.pdf_generator import update_page_size_in_css, validate_page_size_in_css

    template = QuotePDFTemplate.query.filter_by(page_size=page_size).first()
    saved_css = None  # Initialize saved_css to avoid UnboundLocalError
    if template:
        current_app.logger.info(
            f"[PDF_PREVIEW] Retrieved saved quote template - PageSize: '{page_size}', TemplateID: {template.id}, HasCSS: {bool(template.template_css)}"
        )
        if template.template_css and template.template_css.strip():
            # Use the saved template CSS as base, but normalize it first to ensure correct @page size
            saved_css = template.template_css
            # CRITICAL: Normalize the saved template CSS to ensure it has the correct @page size
            saved_css = update_page_size_in_css(saved_css, page_size)
            current_app.logger.info(
                f"[PDF_PREVIEW] Using saved quote template CSS - PageSize: '{page_size}', CSS length: {len(saved_css)}, TemplateID: {template.id}"
            )

        # If editor provided CSS, merge it (editor CSS takes precedence for @page rules)
        if css and css.strip():
            # Extract @page rule from editor CSS if present
            editor_page_match = re.search(r"@page\s*\{[^}]*\}", css, re.IGNORECASE | re.DOTALL)
            if editor_page_match:
                # Editor has @page rule - normalize it and use it, merge with saved CSS
                editor_page_rule = editor_page_match.group(0)
                # Normalize editor's @page rule to correct size FIRST
                editor_page_rule = update_page_size_in_css(editor_page_rule, page_size)
                # Remove @page from saved CSS and add normalized editor's @page rule
                if saved_css:
                    saved_css_no_page = re.sub(r"@page\s*\{[^}]*\}", "", saved_css, flags=re.IGNORECASE | re.DOTALL)
                else:
                    saved_css_no_page = ""
                # Remove @page rule from editor CSS and merge
                editor_css_no_page = css.replace(editor_page_rule, "").strip()
                css = editor_page_rule + "\n" + saved_css_no_page
                if editor_css_no_page:
                    css = css + "\n" + editor_css_no_page
            else:
                # No @page in editor CSS, use saved CSS (already normalized) and add editor CSS
                if saved_css:
                    css = saved_css + "\n" + css
                # else: css already has the editor CSS, no need to merge
        else:
            # No editor CSS, use saved template CSS (already normalized) if available
            if saved_css:
                css = saved_css
    elif not css or not css.strip():
        # No template CSS and no editor CSS - create default with correct page size
        css = f"@page {{\n    size: {page_size};\n    margin: 2cm;\n}}\n"

    # Normalize @page size in CSS to match the selected page size
    # This ensures preview matches what will be exported
    if css:
        # Always normalize @page size to ensure it matches the selected page size
        css_before = css
        css = update_page_size_in_css(css, page_size)

        # Log if normalization changed anything
        if css != css_before:
            current_app.logger.debug(
                f"Quote PDF Preview - CSS @page size normalized from template/editor to {page_size}"
            )

        # Validate after normalization
        is_valid, found_sizes = validate_page_size_in_css(css, page_size)
        if not is_valid:
            current_app.logger.warning(
                f"[PDF_PREVIEW] Quote CSS @page size normalization failed - PageSize: '{page_size}', Found sizes: {found_sizes}, User: {current_user.username}"
            )
            # Force add @page rule if validation failed
            if "@page" not in css:
                css = f"@page {{\n    size: {page_size};\n    margin: 2cm;\n}}\n\n" + css
            else:
                # Try to fix it by replacing any existing @page size
                # Use a more robust regex that handles quotes and whitespace
                css = re.sub(
                    r"size\s*:\s*['\"]?[^;}\n]+['\"]?", f"size: {page_size}", css, flags=re.IGNORECASE | re.MULTILINE
                )
    else:
        # No CSS provided, add default @page rule
        css = update_page_size_in_css("", page_size)

    # Final validation and logging
    is_valid, found_sizes = validate_page_size_in_css(css, page_size)
    if is_valid:
        current_app.logger.info(
            f"[PDF_PREVIEW] Quote CSS validated successfully - PageSize: '{page_size}', Final CSS length: {len(css)}, Final HTML length: {len(html)}"
        )
    else:
        current_app.logger.error(
            f"[PDF_PREVIEW] Quote CSS validation FAILED - PageSize: '{page_size}', Found sizes: {found_sizes}, User: {current_user.username}"
        )

    # Helper: remove @page rules from HTML inline styles when separate CSS exists
    # This matches the fix used in PDF exports to avoid conflicts with WeasyPrint
    def remove_page_rule_from_html(html_text):
        """Remove @page rules from HTML inline styles to avoid conflicts with separate CSS"""
        import re

        def remove_from_style_tag(match):
            style_content = match.group(2)
            # Remove @page rule from style content
            # Need to handle nested @bottom-center rules properly
            # Match @page { ... } including any nested rules
            brace_count = 0
            page_pattern = r"@page\s*\{"
            page_match = re.search(page_pattern, style_content, re.IGNORECASE)

            if page_match:
                start = page_match.start()
                # Find matching closing brace
                end = len(style_content)
                for i in range(page_match.end() - 1, len(style_content)):
                    if style_content[i] == "{":
                        brace_count += 1
                    elif style_content[i] == "}":
                        brace_count -= 1
                        if brace_count == 0:
                            end = i + 1
                            break
                # Remove the @page rule
                style_content = style_content[:start] + style_content[end:]
                # Clean up any double newlines or extra whitespace
                style_content = re.sub(r"\n\s*\n", "\n", style_content)

            return f"{match.group(1)}{style_content}{match.group(3)}"

        # Match <style> tags and remove @page rules from them
        style_pattern = r"(<style[^>]*>)(.*?)(</style>)"
        if re.search(style_pattern, html_text, re.IGNORECASE | re.DOTALL):
            html_text = re.sub(style_pattern, remove_from_style_tag, html_text, flags=re.IGNORECASE | re.DOTALL)

        return html_text

    # Apply @page rule removal fix: if we have separate CSS and HTML with inline styles,
    # remove @page rules from HTML to ensure the separate CSS @page rule is used
    html_has_inline_styles = html and "<style>" in html
    if html_has_inline_styles and css and css.strip():
        # Check if HTML has @page rules
        import re

        html_page_rules = re.findall(r"@page\s*\{[^}]*\}", html, re.IGNORECASE | re.DOTALL)
        if html_page_rules:
            current_app.logger.debug(
                f"PDF preview: Found {len(html_page_rules)} @page rule(s) in HTML inline styles - removing them"
            )
            # Remove @page rules from HTML inline styles (keep everything else)
            html = remove_page_rule_from_html(html)
            current_app.logger.debug("PDF preview: Removed @page rules from HTML inline styles")

    # Helper: sanitize Jinja blocks to fix entities/smart quotes inserted by editor
    def _sanitize_jinja_blocks(raw: str) -> str:
        try:
            import html as _html
            import re as _re

            smart_map = {
                "\u201c": '"',
                "\u201d": '"',  # " " -> "
                "\u2018": "'",
                "\u2019": "'",  # ' ' -> '
                "\u00a0": " ",  # nbsp
                "\u200b": "",
                "\u200c": "",
                "\u200d": "",  # zero-width
            }

            def _fix_quotes(s: str) -> str:
                for k, v in smart_map.items():
                    s = s.replace(k, v)
                return s

            def _clean(match):
                open_tag = match.group(1)
                inner = match.group(2)
                # Remove any HTML tags GrapesJS may have inserted inside Jinja braces
                inner = _re.sub(r"</?[^>]+?>", "", inner)
                # Decode HTML entities
                inner = _html.unescape(inner)
                # Fix smart quotes and nbsp
                inner = _fix_quotes(inner)
                # Trim excessive whitespace around pipes and parentheses
                inner = _re.sub(r"\s+\|\s+", " | ", inner)
                inner = _re.sub(r"\(\s+", "(", inner)
                inner = _re.sub(r"\s+\)", ")", inner)
                # Normalize _("...") -> _('...')
                inner = inner.replace('_("', "_('").replace('")', "')")
                return f"{open_tag}{inner}{' }}' if open_tag == '{{ ' else ' %}'}"

            pattern = _re.compile(r"({{\s|{%\s)([\s\S]*?)(?:}}|%})")
            return _re.sub(pattern, _clean, raw)
        except Exception:
            return raw

    sanitized = _sanitize_jinja_blocks(html)

    # Wrap provided HTML with a minimal page and CSS
    try:
        from pathlib import Path as _Path

        # Provide helpers as callables since templates may use function-style helpers
        try:
            from babel.dates import format_date as _babel_format_date
        except Exception:
            _babel_format_date = None

        def _format_date(value, format="medium"):
            try:
                # Use DD.MM.YYYY format for invoices and quotes
                return value.strftime("%d.%m.%Y") if value else ""
            except Exception:
                return str(value) if value else ""

        def _format_money(value):
            try:
                return f"{float(value):,.2f} {settings_obj.currency}"
            except Exception:
                return f"{value} {settings_obj.currency}"

        # Helper function for logo - converts to base64 data URI
        def _get_logo_base64(logo_path):
            try:
                if not logo_path or not os.path.exists(logo_path):
                    return None
                import base64
                import mimetypes

                with open(logo_path, "rb") as f:
                    data = base64.b64encode(f.read()).decode("utf-8")
                mime_type, _ = mimetypes.guess_type(logo_path)
                if not mime_type:
                    mime_type = "image/png"
                return f"data:{mime_type};base64,{data}"
            except Exception as e:
                print(f"Error loading logo: {e}")
                return None

        current_app.logger.info(
            f"[PDF_PREVIEW] Rendering quote template string - PageSize: '{page_size}', QuoteID: {quote_id}, Sanitized HTML length: {len(sanitized)}"
        )
        body_html = render_sandboxed_string(
            sanitized,
            autoescape=True,
            quote=quote,
            settings=settings_obj,
            Path=_Path,
            format_date=_format_date,
            format_money=_format_money,
            get_logo_base64=_get_logo_base64,
            item=sample_item,
        )
        current_app.logger.info(
            f"[PDF_PREVIEW] Quote template rendered successfully - PageSize: '{page_size}', Rendered HTML length: {len(body_html)}"
        )
    except Exception as e:
        import traceback

        error_details = traceback.format_exc()
        current_app.logger.error(
            f"[PDF_PREVIEW] Quote template render error - PageSize: '{page_size}', Error: {str(e)}, User: {current_user.username}",
            exc_info=True,
        )
        body_html = (
            f"<div style='color:red; padding:20px; border:2px solid red; margin:20px;'><h3>Template error:</h3><pre>{str(e)}</pre><pre>{error_details}</pre></div>"
            + sanitized
        )
    # Get page dimensions for preview styling
    from app.models import QuotePDFTemplate

    page_dimensions = QuotePDFTemplate.PAGE_SIZES.get(page_size, QuotePDFTemplate.PAGE_SIZES["A4"])
    page_width_mm = page_dimensions["width"]
    page_height_mm = page_dimensions["height"]
    # Convert mm to pixels at 96 DPI (standard browser DPI for PDF preview)
    # 1 inch = 25.4mm, 96 DPI = 96 pixels per inch
    # Account for margins (typically 20mm = ~75px at 96 DPI)
    margin_px = int((20 / 25.4) * 96)  # 20mm margin in pixels
    # Don't subtract margins from page dimensions - margins are applied to content, not page size
    page_width_px = int((page_width_mm / 25.4) * 96)
    page_height_px = int((page_height_mm / 25.4) * 96)

    # Build complete HTML page with embedded styles
    # For preview, scale to fit viewport while maintaining aspect ratio
    page_html = f"""<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Quote Preview ({page_size})</title>
    <style>{css}
/* Preview-specific styles - completely new approach */
* {{
    box-sizing: border-box;
    margin: 0;
    padding: 0;
}}
html {{
    width: 100% !important;
    margin: 0 !important;
    padding: 0 !important;
    overflow: auto !important;
    overflow-x: auto !important;
    overflow-y: auto !important;
    background-color: #e5e7eb !important;
}}
body {{
    width: 100% !important;
    min-width: 100% !important;
    margin: 0 !important;
    padding: 20px !important;
    padding-top: 20px !important;
    overflow: auto !important;
    overflow-x: auto !important;
    overflow-y: auto !important;
    background-color: #e5e7eb !important;
    display: flex !important;
    align-items: flex-start !important;
    justify-content: center !important;
    box-sizing: border-box !important;
    min-height: 100vh !important;
}}
.preview-container {{
    background: white;
    box-shadow: 0 4px 20px rgba(0,0,0,0.15);
    border-radius: 8px;
    overflow: visible;
    position: relative;
    width: {page_width_px}px;
    min-width: {page_width_px}px;
    max-width: {page_width_px}px;
    display: flex;
    align-items: flex-start;
    justify-content: center;
    transition: transform 0.2s ease;
    transform-origin: top center;
    margin-top: 20px;
}}
.preview-container .invoice-wrapper,
.preview-container .quote-wrapper {{
    width: {page_width_px}px !important;
    min-width: {page_width_px}px !important;
    max-width: {page_width_px}px !important;
    height: {page_height_px}px !important;
    min-height: {page_height_px}px !important;
    max-height: {page_height_px}px !important;
    box-sizing: border-box !important;
    overflow: visible !important;
    margin: 0 auto !important;
    padding: 0 !important;
    background: transparent !important;
    position: relative;
    /* CSS zoom on container will scale this proportionally */
}}
</style>
<!-- Zoom is now controlled by the parent iframe's JavaScript -->
<script>
// Minimal script - zoom is handled by parent window
(function() {{
    // Ensure container maintains correct dimensions
    const container = document.querySelector('.preview-container');
    if (container) {{
        container.style.width = {page_width_px} + 'px';
        container.style.minWidth = {page_width_px} + 'px';
        container.style.maxWidth = {page_width_px} + 'px';
    }}
}})();
</script>
</head>
<body>
<div class="preview-container">
{body_html}
</div>
</body>
</html>"""
    current_app.logger.info(
        f"[PDF_PREVIEW] Returning quote preview HTML - PageSize: '{page_size}', Total HTML length: {len(page_html)}, PageWidth: {page_width_px}px, PageHeight: {page_height_px}px, User: {current_user.username}"
    )
    return page_html


@admin_bp.route("/admin/upload-logo", methods=["POST"])
@limiter.limit("10 per minute")
@login_required
@admin_or_permission_required("manage_settings")
def upload_logo():
    """Upload company logo"""
    if "logo" not in request.files:
        flash(_("No logo file selected"), "error")
        return redirect(url_for("admin.settings"))

    file = request.files["logo"]
    if file.filename == "":
        flash(_("No logo file selected"), "error")
        return redirect(url_for("admin.settings"))

    if file and allowed_logo_file(file.filename):
        # Generate unique filename
        file_extension = file.filename.rsplit(".", 1)[1].lower()
        unique_filename = f"company_logo_{uuid.uuid4().hex[:8]}.{file_extension}"

        # Basic server-side validation: verify image type
        try:
            from PIL import Image

            file.stream.seek(0)
            img = Image.open(file.stream)
            img.verify()
            file.stream.seek(0)
        except Exception:
            flash(_("Invalid image file."), "error")
            return redirect(url_for("admin.settings"))

        # Save file
        upload_folder = get_upload_folder()
        file_path = os.path.join(upload_folder, unique_filename)
        file.save(file_path)

        # Log successful save
        current_app.logger.info(f"Logo saved successfully: {file_path}")
        current_app.logger.info(f"File exists check: {os.path.exists(file_path)}")
        current_app.logger.info(
            f'File size: {os.path.getsize(file_path) if os.path.exists(file_path) else "N/A"} bytes'
        )

        # Update settings
        settings_obj = Settings.get_settings()

        # Remove old logo if it exists
        if settings_obj.company_logo_filename:
            old_logo_path = os.path.join(upload_folder, settings_obj.company_logo_filename)
            if os.path.exists(old_logo_path):
                try:
                    os.remove(old_logo_path)
                except OSError:
                    pass  # Ignore errors when removing old file

        settings_obj.company_logo_filename = unique_filename
        if not safe_commit("admin_upload_logo"):
            flash(_("Could not save logo due to a database error. Please check server logs."), "error")
            return redirect(url_for("admin.settings"))

        flash(
            _(
                'Company logo uploaded successfully! You can see it in the "Current Company Logo" section above. It will appear on invoices and PDF documents.'
            ),
            "success",
        )
    else:
        flash(_("Invalid file type. Allowed types: PNG, JPG, JPEG, GIF, SVG, WEBP"), "error")

    return redirect(url_for("admin.settings"))


@admin_bp.route("/admin/remove-logo", methods=["POST"])
@login_required
@admin_or_permission_required("manage_settings")
def remove_logo():
    """Remove company logo"""
    settings_obj = Settings.get_settings()

    if settings_obj.company_logo_filename:
        # Remove file from filesystem
        logo_path = settings_obj.get_logo_path()
        if logo_path and os.path.exists(logo_path):
            try:
                os.remove(logo_path)
            except OSError:
                pass  # Ignore errors when removing file

        # Clear filename from database
        settings_obj.company_logo_filename = ""
        if not safe_commit("admin_remove_logo"):
            flash(_("Could not remove logo due to a database error. Please check server logs."), "error")
            return redirect(url_for("admin.settings"))
        flash(_("Company logo removed successfully. Upload a new logo in the section below if needed."), "success")
    else:
        flash(_("No logo to remove"), "info")

    return redirect(url_for("admin.settings"))


@admin_bp.route("/admin/template-image/upload", methods=["POST"])
@limiter.limit("10 per minute")
@login_required
@admin_or_permission_required("manage_settings")
def upload_template_image():
    """Upload an image for use in PDF templates"""
    import os
    from datetime import datetime

    from flask import url_for
    from werkzeug.utils import secure_filename

    # File upload configuration - only images
    ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "webp"}
    UPLOAD_FOLDER = "app/static/uploads/template_images"
    MAX_FILE_SIZE = 5 * 1024 * 1024  # 5 MB

    def allowed_file(filename):
        return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS

    if "file" not in request.files:
        return jsonify({"error": "No file provided"}), 400

    file = request.files["file"]
    if file.filename == "":
        return jsonify({"error": "No file selected"}), 400

    if not allowed_file(file.filename):
        return jsonify({"error": "File type not allowed. Only images (PNG, JPG, JPEG, GIF, WEBP) are allowed"}), 400

    # Check file size
    file.seek(0, os.SEEK_END)
    file_size = file.tell()
    file.seek(0)

    if file_size > MAX_FILE_SIZE:
        return jsonify({"error": f"File size exceeds maximum allowed size ({MAX_FILE_SIZE / (1024*1024):.0f} MB)"}), 400

    # Save file
    original_filename = secure_filename(file.filename)
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    filename = f"template_{timestamp}_{original_filename}"

    # Ensure upload directory exists
    upload_dir = os.path.join(current_app.root_path, "..", UPLOAD_FOLDER)
    os.makedirs(upload_dir, exist_ok=True)

    file_path = os.path.join(upload_dir, filename)
    file.save(file_path)

    # Return URL for the image
    image_url = url_for("admin.serve_template_image", filename=filename)

    return jsonify({"success": True, "image_url": image_url, "filename": filename})


@admin_bp.route("/uploads/template_images/<path:filename>")
def serve_template_image(filename):
    """Serve uploaded template images (public route so images can be embedded in PDFs)"""
    import os

    from flask import send_from_directory

    upload_folder = os.path.join(current_app.root_path, "..", "app/static/uploads/template_images")
    return send_from_directory(upload_folder, filename)


# Public route to serve uploaded logos from the static uploads directory
@admin_bp.route("/uploads/logos/<path:filename>")
def serve_uploaded_logo(filename):
    """Serve company logo files stored under static/uploads/logos.
    This route is intentionally public so logos render on unauthenticated pages
    like the login screen and in favicons.
    """
    try:
        upload_folder = get_upload_folder()
        file_path = os.path.join(upload_folder, filename)

        if not os.path.exists(file_path):
            current_app.logger.error(f"Logo file not found: {file_path}")
            return "Logo file not found", 404

        return send_from_directory(upload_folder, filename)
    except Exception as e:
        current_app.logger.error(f"Error serving logo {filename}: {str(e)}")
        return "Error serving logo", 500


@admin_bp.route("/admin/backups")
@login_required
@admin_or_permission_required("manage_backups")
def backups_management():
    """Backups management page"""
    # Get list of existing backups
    backups_dir = get_backup_root_dir(current_app)
    backups = []

    if os.path.exists(backups_dir):
        for filename in os.listdir(backups_dir):
            if filename.endswith(".zip") and not filename.startswith("restore_"):
                filepath = os.path.join(backups_dir, filename)
                stat = os.stat(filepath)
                backups.append(
                    {
                        "filename": filename,
                        "size": stat.st_size,
                        "created": datetime.fromtimestamp(stat.st_mtime),
                        "size_mb": round(stat.st_size / (1024 * 1024), 2),
                    }
                )

    # Sort by creation date (newest first)
    backups.sort(key=lambda x: x["created"], reverse=True)

    return render_template("admin/backups.html", backups=backups, backups_dir=backups_dir)


@admin_bp.route("/admin/backup/create", methods=["POST"])
@login_required
@admin_or_permission_required("manage_backups")
def create_backup_manual():
    """Create manual backup and return the archive for download."""
    try:
        archive_path = create_backup(current_app)
        if not archive_path or not os.path.exists(archive_path):
            flash(_("Backup failed: archive not created"), "error")
            return redirect(url_for("admin.backups_management"))
        # Stream file to user
        return send_file(archive_path, as_attachment=True)
    except Exception as e:
        flash(_("Backup failed: %(error)s", error=str(e)), "error")
        return redirect(url_for("admin.backups_management"))


@admin_bp.route("/admin/backup/download/<filename>")
@login_required
@admin_or_permission_required("manage_backups")
def download_backup(filename):
    """Download an existing backup file"""
    # Security: only allow downloading .zip files, no path traversal
    filename = secure_filename(filename)
    if not filename.endswith(".zip"):
        flash(_("Invalid file type"), "error")
        return redirect(url_for("admin.backups_management"))

    backups_dir = get_backup_root_dir(current_app)
    filepath = os.path.join(backups_dir, filename)

    if not os.path.exists(filepath):
        flash(_("Backup file not found"), "error")
        return redirect(url_for("admin.backups_management"))

    return send_file(filepath, as_attachment=True)


@admin_bp.route("/admin/backup/delete/<filename>", methods=["POST"])
@login_required
@admin_or_permission_required("manage_backups")
def delete_backup(filename):
    """Delete a backup file"""
    # Security: only allow deleting .zip files, no path traversal
    filename = secure_filename(filename)
    if not filename.endswith(".zip"):
        flash(_("Invalid file type"), "error")
        return redirect(url_for("admin.backups_management"))

    backups_dir = get_backup_root_dir(current_app)
    filepath = os.path.join(backups_dir, filename)

    try:
        if os.path.exists(filepath):
            os.remove(filepath)
            flash(_('Backup "%(filename)s" deleted successfully', filename=filename), "success")
        else:
            flash(_("Backup file not found"), "error")
    except Exception as e:
        flash(_("Failed to delete backup: %(error)s", error=str(e)), "error")

    return redirect(url_for("admin.backups_management"))


@admin_bp.route("/admin/restore", methods=["GET", "POST"])
@admin_bp.route("/admin/restore/<filename>", methods=["POST"])
@limiter.limit("3 per minute", methods=["POST"])  # heavy operation
@login_required
@admin_or_permission_required("manage_backups")
def restore(filename=None):
    """Restore from an uploaded backup archive or existing backup file."""
    if request.method == "POST":
        backups_dir = get_backup_root_dir(current_app)

        # If restoring from an existing backup file
        if filename:
            filename = secure_filename(filename)
            if not filename.lower().endswith(".zip"):
                flash(_("Invalid file type. Please select a .zip backup archive."), "error")
                return redirect(url_for("admin.backups_management"))
            temp_path = os.path.join(backups_dir, filename)
            if not os.path.exists(temp_path):
                flash(_("Backup file not found."), "error")
                return redirect(url_for("admin.backups_management"))
            # Copy to temp location for processing
            actual_restore_path = os.path.join(backups_dir, f"restore_{uuid.uuid4().hex[:8]}_{filename}")
            shutil.copy2(temp_path, actual_restore_path)
            temp_path = actual_restore_path
        # If uploading a new backup file
        elif "backup_file" in request.files and request.files["backup_file"].filename != "":
            file = request.files["backup_file"]
            uploaded_filename = secure_filename(file.filename)
            if not uploaded_filename.lower().endswith(".zip"):
                flash(_("Invalid file type. Please upload a .zip backup archive."), "error")
                return redirect(url_for("admin.restore"))
            # Save temporarily under project backups
            os.makedirs(backups_dir, exist_ok=True)
            temp_path = os.path.join(backups_dir, f"restore_{uuid.uuid4().hex[:8]}_{uploaded_filename}")
            file.save(temp_path)
        else:
            flash(_("No backup file provided"), "error")
            return redirect(url_for("admin.restore"))

        # Initialize progress state
        token = uuid.uuid4().hex[:8]
        RESTORE_PROGRESS[token] = {"status": "starting", "percent": 0, "message": "Queued"}

        def progress_cb(label, percent):
            RESTORE_PROGRESS[token] = {"status": "running", "percent": int(percent), "message": label}

        # Capture the real Flask app object for use in a background thread
        app_obj = current_app._get_current_object()

        def _do_restore():
            try:
                RESTORE_PROGRESS[token] = {"status": "running", "percent": 5, "message": "Starting restore"}
                success, message = restore_backup(app_obj, temp_path, progress_callback=progress_cb)
                RESTORE_PROGRESS[token] = {
                    "status": "done" if success else "error",
                    "percent": 100 if success else RESTORE_PROGRESS[token].get("percent", 0),
                    "message": message,
                }
            except Exception as e:
                RESTORE_PROGRESS[token] = {
                    "status": "error",
                    "percent": RESTORE_PROGRESS[token].get("percent", 0),
                    "message": str(e),
                }
            finally:
                safe_file_remove(temp_path, current_app.logger)

        # Run restore in background to keep request responsive
        t = threading.Thread(target=_do_restore, daemon=True)
        t.start()

        flash(_("Restore started. You can monitor progress on this page."), "info")
        return redirect(url_for("admin.restore", token=token))
    # GET
    token = request.args.get("token")
    progress = RESTORE_PROGRESS.get(token) if token else None
    return render_template("admin/restore.html", progress=progress, token=token)


@admin_bp.route("/admin/system")
@login_required
@admin_or_permission_required("view_system_info")
def system_info():
    """Show system information"""
    # Get system statistics
    total_users = User.query.count()
    total_projects = Project.query.count()
    total_entries = TimeEntry.query.count()
    active_timers = TimeEntry.query.filter_by(end_time=None).count()

    # Get database size
    db_size_bytes = 0
    try:
        engine = db.session.bind
        dialect = engine.dialect.name if engine else ""
        if dialect == "sqlite":
            db_size_bytes = (
                db.session.execute(
                    text("SELECT page_count * page_size AS size FROM pragma_page_count(), pragma_page_size()")
                ).scalar()
                or 0
            )
        elif dialect in ("postgresql", "postgres"):
            db_size_bytes = db.session.execute(text("SELECT pg_database_size(current_database())")).scalar() or 0
        else:
            db_size_bytes = 0
    except Exception:
        db_size_bytes = 0
    db_size_mb = round(db_size_bytes / (1024 * 1024), 2) if db_size_bytes else 0

    return render_template(
        "admin/system_info.html",
        total_users=total_users,
        total_projects=total_projects,
        total_entries=total_entries,
        active_timers=active_timers,
        db_size_mb=db_size_mb,
    )


@admin_bp.route("/admin/oidc/debug")
@login_required
@admin_or_permission_required("manage_oidc")
def oidc_debug():
    """OIDC Configuration Debug Dashboard"""
    from app import oauth
    from app.config import Config

    # Gather OIDC configuration
    oidc_config = {
        "enabled": False,
        "auth_method": getattr(Config, "AUTH_METHOD", "local"),
        "issuer": getattr(Config, "OIDC_ISSUER", None),
        "client_id": getattr(Config, "OIDC_CLIENT_ID", None),
        "client_secret_set": bool(getattr(Config, "OIDC_CLIENT_SECRET", None)),
        "redirect_uri": getattr(Config, "OIDC_REDIRECT_URI", None),
        "scopes": getattr(Config, "OIDC_SCOPES", "openid profile email"),
        "username_claim": getattr(Config, "OIDC_USERNAME_CLAIM", "preferred_username"),
        "email_claim": getattr(Config, "OIDC_EMAIL_CLAIM", "email"),
        "full_name_claim": getattr(Config, "OIDC_FULL_NAME_CLAIM", "name"),
        "groups_claim": getattr(Config, "OIDC_GROUPS_CLAIM", "groups"),
        "admin_group": getattr(Config, "OIDC_ADMIN_GROUP", None),
        "admin_emails": getattr(Config, "OIDC_ADMIN_EMAILS", []),
        "post_logout_redirect": getattr(Config, "OIDC_POST_LOGOUT_REDIRECT_URI", None),
    }

    # Check if OIDC is enabled
    auth_method = normalize_auth_method(oidc_config["auth_method"] or "local")
    oidc_config["enabled"] = auth_includes_oidc(auth_method)

    # Try to get OIDC client metadata
    metadata = None
    metadata_error = None
    well_known_url = None

    if oidc_config["enabled"] and oidc_config["issuer"]:
        try:
            client = oauth.create_client("oidc")
            if client:
                metadata = client.load_server_metadata()
                well_known_url = f"{oidc_config['issuer'].rstrip('/')}/.well-known/openid-configuration"
        except Exception as e:
            metadata_error = str(e)
            well_known_url = (
                f"{oidc_config['issuer'].rstrip('/')}/.well-known/openid-configuration"
                if oidc_config["issuer"]
                else None
            )

    # Get OIDC users from database
    oidc_users = []
    try:
        oidc_users = (
            User.query.filter(User.oidc_issuer.isnot(None), User.oidc_sub.isnot(None))
            .order_by(User.last_login.desc())
            .all()
        )
    except Exception as e:
        safe_log(current_app.logger, "debug", "OIDC users query failed (columns may not exist): %s", e)

    return render_template(
        "admin/oidc_debug.html",
        oidc_config=oidc_config,
        metadata=metadata,
        metadata_error=metadata_error,
        well_known_url=well_known_url,
        oidc_users=oidc_users,
    )


@admin_bp.route("/admin/oidc/test")
@limiter.limit("10 per minute")
@login_required
@admin_or_permission_required("manage_oidc")
def oidc_test():
    """Test OIDC configuration by fetching discovery document with enhanced DNS testing"""
    from urllib.parse import urlparse

    from app import oauth
    from app.config import Config
    from app.utils.oidc_metadata import (
        detect_docker_environment,
        fetch_oidc_metadata,
        resolve_hostname_multiple_strategies,
        test_dns_resolution,
    )

    auth_method = normalize_auth_method(getattr(Config, "AUTH_METHOD", "local"))
    if not auth_includes_oidc(auth_method):
        flash(_('OIDC is not enabled. Set AUTH_METHOD to "oidc", "both", or "all".'), "warning")
        return redirect(url_for("admin.oidc_debug"))

    issuer = getattr(Config, "OIDC_ISSUER", None)
    if not issuer:
        flash(_("OIDC_ISSUER is not configured"), "error")
        return redirect(url_for("admin.oidc_debug"))

    # Parse hostname
    try:
        parsed = urlparse(issuer)
        hostname = parsed.netloc.split(":")[0]
    except Exception as e:
        flash(_("✗ Failed to parse issuer URL: %(error)s", error=str(e)), "error")
        return redirect(url_for("admin.oidc_debug"))

    # Test 1: Test DNS resolution with multiple strategies
    flash(_("Testing DNS resolution with multiple strategies..."), "info")
    dns_strategy = current_app.config.get("OIDC_DNS_RESOLUTION_STRATEGY", "auto")

    # Test all strategies
    strategies_to_test = (
        ["socket", "getaddrinfo"] if dns_strategy == "auto" or dns_strategy == "both" else [dns_strategy]
    )
    dns_results = {}

    for strategy in strategies_to_test:
        success, ip, error, strategy_used = resolve_hostname_multiple_strategies(
            hostname, timeout=5, strategy=strategy, use_cache=False
        )
        dns_results[strategy] = {
            "success": success,
            "ip": ip,
            "error": error,
            "strategy_used": strategy_used,
        }
        if success:
            # Mask IP for display (show only first octet)
            masked_ip = ip.split(".")[0] + ".xxx.xxx.xxx" if ip and "." in ip else "N/A"
            flash(
                _("✓ DNS resolution successful using %(strategy)s strategy: %(ip)s", strategy=strategy, ip=masked_ip),
                "success",
            )
        else:
            flash(
                _(
                    "✗ DNS resolution failed using %(strategy)s strategy: %(error)s",
                    strategy=strategy,
                    error=error or "Unknown error",
                ),
                "warning",
            )

    # Check Docker environment
    if detect_docker_environment():
        flash(_("ℹ Docker environment detected - internal service names may be available"), "info")

    # Test 2: Fetch discovery document using enhanced metadata fetcher
    well_known_url = f"{issuer.rstrip('/')}/.well-known/openid-configuration"
    use_ip_directly = current_app.config.get("OIDC_USE_IP_DIRECTLY", True)
    use_docker_internal = current_app.config.get("OIDC_USE_DOCKER_INTERNAL", True)
    max_retries = int(current_app.config.get("OIDC_METADATA_RETRY_ATTEMPTS", 3))
    timeout = int(current_app.config.get("OIDC_METADATA_FETCH_TIMEOUT", 10))

    try:
        current_app.logger.info("OIDC Test: Fetching discovery document from %s", well_known_url)
        metadata, metadata_error, diagnostics = fetch_oidc_metadata(
            issuer,
            max_retries=max_retries,
            retry_delay=2,
            timeout=timeout,
            use_dns_test=True,
            dns_strategy=dns_strategy,
            use_ip_directly=use_ip_directly,
            use_docker_internal=use_docker_internal,
        )

        if metadata:
            discovery_doc = metadata
            flash(_("✓ Discovery document fetched successfully from %(url)s", url=well_known_url), "success")
            if diagnostics:
                dns_info = diagnostics.get("dns_resolution", {})
                strategy_used = dns_info.get("strategy", "unknown")
                flash(
                    _("✓ DNS strategy used: %(strategy)s", strategy=strategy_used),
                    "info",
                )
            current_app.logger.info("OIDC Test: Discovery document retrieved, issuer=%s", discovery_doc.get("issuer"))
        else:
            flash(
                _("✗ Failed to fetch discovery document: %(error)s", error=metadata_error or "Unknown error"), "error"
            )
            current_app.logger.error("OIDC Test: Failed to fetch discovery document: %s", metadata_error)
            return redirect(url_for("admin.oidc_debug"))
    except Exception as e:
        flash(_("✗ Unexpected error: %(error)s", error=str(e)), "error")
        current_app.logger.error("OIDC Test: Unexpected error: %s", str(e))
        return redirect(url_for("admin.oidc_debug"))

    # Ensure discovery_doc is defined
    if "discovery_doc" not in locals():
        flash(_("✗ Failed to retrieve discovery document"), "error")
        return redirect(url_for("admin.oidc_debug"))

    # Test 2: Check if OAuth client is registered
    try:
        client = oauth.create_client("oidc")
        if client:
            flash(_("✓ OAuth client is registered in application"), "success")
            current_app.logger.info("OIDC Test: OAuth client registered")
        else:
            flash(_("✗ OAuth client is not registered"), "error")
            current_app.logger.error("OIDC Test: OAuth client not registered")
    except Exception as e:
        flash(_("✗ Failed to create OAuth client: %(error)s", error=str(e)), "error")
        current_app.logger.error("OIDC Test: Failed to create OAuth client: %s", str(e))

    # Test 3: Verify required endpoints are present
    required_endpoints = ["authorization_endpoint", "token_endpoint", "userinfo_endpoint"]
    for endpoint in required_endpoints:
        if endpoint in discovery_doc:
            flash(_("✓ %(endpoint)s: %(url)s", endpoint=endpoint, url=discovery_doc[endpoint]), "info")
        else:
            flash(_("✗ Missing %(endpoint)s in discovery document", endpoint=endpoint), "warning")

    # Test 4: Check supported scopes
    supported_scopes = discovery_doc.get("scopes_supported", [])
    requested_scopes = getattr(Config, "OIDC_SCOPES", "openid profile email").split()
    for scope in requested_scopes:
        if scope in supported_scopes:
            flash(_('✓ Scope "%(scope)s" is supported by provider', scope=scope), "info")
        else:
            flash(
                _(
                    '⚠ Scope "%(scope)s" may not be supported by provider (supported: %(supported)s)',
                    scope=scope,
                    supported=", ".join(supported_scopes),
                ),
                "warning",
            )

    # Test 5: Check claims
    supported_claims = discovery_doc.get("claims_supported", [])
    if supported_claims:
        flash(_("ℹ Provider supports claims: %(claims)s", claims=", ".join(supported_claims)), "info")

        # Check if configured claims are supported
        claim_checks = {
            "username": getattr(Config, "OIDC_USERNAME_CLAIM", "preferred_username"),
            "email": getattr(Config, "OIDC_EMAIL_CLAIM", "email"),
            "full_name": getattr(Config, "OIDC_FULL_NAME_CLAIM", "name"),
            "groups": getattr(Config, "OIDC_GROUPS_CLAIM", "groups"),
        }

        for claim_type, claim_name in claim_checks.items():
            if claim_name in supported_claims:
                flash(
                    _(
                        '✓ Configured %(claim_type)s claim "%(claim_name)s" is supported',
                        claim_type=claim_type,
                        claim_name=claim_name,
                    ),
                    "info",
                )
            else:
                flash(
                    _(
                        '⚠ Configured %(claim_type)s claim "%(claim_name)s" not in supported claims list (may still work)',
                        claim_type=claim_type,
                        claim_name=claim_name,
                    ),
                    "warning",
                )

    flash(_("OIDC configuration test completed"), "info")
    return redirect(url_for("admin.oidc_debug"))


@admin_bp.route("/admin/oidc/user/<int:user_id>")
@login_required
@admin_or_permission_required("view_users")
def oidc_user_detail(user_id):
    """View OIDC details for a specific user"""
    user = User.query.get_or_404(user_id)

    return render_template("admin/oidc_user_detail.html", user=user)


# ==================== OIDC Setup Wizard ====================


@admin_bp.route("/admin/oidc/setup-wizard")
@login_required
@admin_or_permission_required("manage_oidc")
def oidc_setup_wizard():
    """Guided OIDC setup wizard"""
    from app.config import Config

    # Get current configuration if any
    current_config = {
        "auth_method": getattr(Config, "AUTH_METHOD", "local"),
        "issuer": getattr(Config, "OIDC_ISSUER", ""),
        "client_id": getattr(Config, "OIDC_CLIENT_ID", ""),
        "client_secret_set": bool(getattr(Config, "OIDC_CLIENT_SECRET", None)),
        "redirect_uri": getattr(Config, "OIDC_REDIRECT_URI", ""),
        "scopes": getattr(Config, "OIDC_SCOPES", "openid profile email"),
        "username_claim": getattr(Config, "OIDC_USERNAME_CLAIM", "preferred_username"),
        "email_claim": getattr(Config, "OIDC_EMAIL_CLAIM", "email"),
        "full_name_claim": getattr(Config, "OIDC_FULL_NAME_CLAIM", "name"),
        "groups_claim": getattr(Config, "OIDC_GROUPS_CLAIM", "groups"),
        "admin_group": getattr(Config, "OIDC_ADMIN_GROUP", ""),
        "admin_emails": ",".join(getattr(Config, "OIDC_ADMIN_EMAILS", [])),
        "post_logout_redirect": getattr(Config, "OIDC_POST_LOGOUT_REDIRECT_URI", ""),
    }

    # Generate redirect URI if not set
    if not current_config["redirect_uri"]:
        current_config["redirect_uri"] = url_for("auth.oidc_callback", _external=True)

    return render_template("admin/oidc_setup_wizard.html", current_config=current_config)


@admin_bp.route("/admin/oidc/setup-wizard/test-connection", methods=["POST"])
@limiter.limit("10 per minute")
@login_required
@admin_or_permission_required("manage_oidc")
def oidc_wizard_test_connection():
    """Test DNS resolution and metadata fetch for OIDC issuer"""
    from urllib.parse import urlparse

    from app.utils.oidc_metadata import fetch_oidc_metadata, resolve_hostname_multiple_strategies, test_dns_resolution

    data = request.get_json() or {}
    issuer = data.get("issuer", "").strip()

    if not issuer:
        return jsonify({"success": False, "error": "Issuer URL is required"}), 400

    # Validate URL format
    try:
        parsed = urlparse(issuer)
        if not parsed.scheme or not parsed.netloc:
            return jsonify({"success": False, "error": "Invalid URL format"}), 400
        hostname = parsed.netloc.split(":")[0]
    except Exception as e:
        return jsonify({"success": False, "error": f"Invalid URL: {str(e)}"}), 400

    result = {
        "success": False,
        "dns_resolved": False,
        "metadata": None,
        "error": None,
        "hostname": hostname,
    }

    # Test DNS resolution with multiple strategies
    dns_strategy = current_app.config.get("OIDC_DNS_RESOLUTION_STRATEGY", "auto")
    dns_success, dns_ip, dns_error, dns_strategy_used = test_dns_resolution(hostname, timeout=5, strategy=dns_strategy)
    result["dns_resolved"] = dns_success
    result["dns_strategy"] = dns_strategy_used
    result["dns_ip"] = dns_ip  # Will be masked in response
    if not dns_success:
        result["error"] = dns_error
        return jsonify(result), 200  # Return 200 but with success=False

    # Fetch metadata
    use_ip_directly = current_app.config.get("OIDC_USE_IP_DIRECTLY", True)
    use_docker_internal = current_app.config.get("OIDC_USE_DOCKER_INTERNAL", True)
    metadata, metadata_error, diagnostics = fetch_oidc_metadata(
        issuer,
        max_retries=3,
        retry_delay=2,
        timeout=10,
        use_dns_test=False,  # Already tested DNS
        dns_strategy=dns_strategy,
        use_ip_directly=use_ip_directly,
        use_docker_internal=use_docker_internal,
    )

    if diagnostics:
        result["diagnostics"] = diagnostics

    if metadata:
        result["success"] = True
        result["metadata"] = metadata
    else:
        result["error"] = metadata_error

    return jsonify(result), 200


@admin_bp.route("/admin/oidc/setup-wizard/validate-config", methods=["POST"])
@limiter.limit("20 per minute")
@login_required
@admin_or_permission_required("manage_oidc")
def oidc_wizard_validate_config():
    """Validate OIDC configuration"""
    from urllib.parse import urlparse

    data = request.get_json() or {}
    errors = []

    # Validate issuer
    issuer = data.get("issuer", "").strip()
    if not issuer:
        errors.append({"field": "issuer", "message": "Issuer URL is required"})
    else:
        try:
            parsed = urlparse(issuer)
            if not parsed.scheme or not parsed.netloc:
                errors.append({"field": "issuer", "message": "Invalid URL format"})
            elif parsed.scheme not in ("http", "https"):
                errors.append({"field": "issuer", "message": "URL must use http or https"})
        except Exception as e:
            errors.append({"field": "issuer", "message": f"Invalid URL: {str(e)}"})

    # Validate client ID
    if not data.get("client_id", "").strip():
        errors.append({"field": "client_id", "message": "Client ID is required"})

    # Validate client secret
    if not data.get("client_secret", "").strip():
        errors.append({"field": "client_secret", "message": "Client Secret is required"})

    # Validate auth method
    auth_method = normalize_auth_method(data.get("auth_method", ""))
    if not auth_includes_oidc(auth_method):
        errors.append({"field": "auth_method", "message": "Auth method must be 'oidc', 'both', or 'all'"})

    # Validate redirect URI if provided
    redirect_uri = data.get("redirect_uri", "").strip()
    if redirect_uri:
        try:
            parsed = urlparse(redirect_uri)
            if not parsed.scheme or not parsed.netloc:
                errors.append({"field": "redirect_uri", "message": "Invalid redirect URI format"})
        except Exception as e:
            errors.append({"field": "redirect_uri", "message": f"Invalid redirect URI: {str(e)}"})

    if errors:
        return jsonify({"valid": False, "errors": errors}), 200

    return jsonify({"valid": True, "errors": []}), 200


@admin_bp.route("/admin/oidc/setup-wizard/generate-config", methods=["POST"])
@limiter.limit("10 per minute")
@login_required
@admin_or_permission_required("manage_oidc")
def oidc_wizard_generate_config():
    """Generate environment variable configuration from wizard data"""

    data = request.get_json() or {}

    # Get base URL for redirect URI generation
    base_url = request.host_url.rstrip("/")
    if not data.get("redirect_uri"):
        redirect_uri = f"{base_url}/auth/oidc/callback"
    else:
        redirect_uri = data.get("redirect_uri", "").strip()

    # Build environment variables
    env_vars = {
        "AUTH_METHOD": data.get("auth_method", "oidc"),
        "OIDC_ISSUER": data.get("issuer", ""),
        "OIDC_CLIENT_ID": data.get("client_id", ""),
        "OIDC_CLIENT_SECRET": data.get("client_secret", ""),
        "OIDC_REDIRECT_URI": redirect_uri,
    }

    # Optional settings
    if data.get("scopes"):
        env_vars["OIDC_SCOPES"] = data.get("scopes")

    if data.get("username_claim"):
        env_vars["OIDC_USERNAME_CLAIM"] = data.get("username_claim")

    if data.get("email_claim"):
        env_vars["OIDC_EMAIL_CLAIM"] = data.get("email_claim")

    if data.get("full_name_claim"):
        env_vars["OIDC_FULL_NAME_CLAIM"] = data.get("full_name_claim")

    if data.get("groups_claim"):
        env_vars["OIDC_GROUPS_CLAIM"] = data.get("groups_claim")

    if data.get("admin_group"):
        env_vars["OIDC_ADMIN_GROUP"] = data.get("admin_group")

    if data.get("admin_emails"):
        env_vars["OIDC_ADMIN_EMAILS"] = data.get("admin_emails")

    if data.get("post_logout_redirect"):
        env_vars["OIDC_POST_LOGOUT_REDIRECT_URI"] = data.get("post_logout_redirect")

    # Generate .env format
    env_lines = []
    for key, value in env_vars.items():
        if value:  # Only include non-empty values
            # Escape special characters in value
            if " " in str(value) or "#" in str(value) or "$" in str(value):
                value = f'"{value}"'
            env_lines.append(f"{key}={value}")

    env_content = "\n".join(env_lines)

    # Generate Docker Compose format
    docker_compose_lines = ["      # OIDC Configuration"]
    for key, value in env_vars.items():
        if value:
            docker_compose_lines.append(f'      - {key}="{value}"')

    docker_compose_content = "\n".join(docker_compose_lines)

    return (
        jsonify(
            {
                "success": True,
                "env_content": env_content,
                "docker_compose_content": docker_compose_content,
                "redirect_uri": redirect_uri,
            }
        ),
        200,
    )


# ==================== LDAP Setup Wizard ====================


def _ldap_wizard_truthy(val) -> bool:
    if isinstance(val, bool):
        return val
    if isinstance(val, (int, float)):
        return bool(val)
    s = str(val or "").strip().lower()
    return s in ("1", "true", "yes", "y", "on")


def _ldap_wizard_int(val, default: int, *, lo: int | None = None, hi: int | None = None) -> int:
    try:
        n = int(val)
    except (TypeError, ValueError):
        n = default
    if lo is not None:
        n = max(lo, n)
    if hi is not None:
        n = min(hi, n)
    return n


def _ldap_wizard_cfg_from_json(data: dict) -> dict[str, object]:
    """Map wizard JSON (LDAP_* keys) to a config-like dict for LDAPService.test_connection."""
    return {
        "LDAP_HOST": (data.get("LDAP_HOST") or "").strip() or "localhost",
        "LDAP_PORT": _ldap_wizard_int(data.get("LDAP_PORT"), 389, lo=1, hi=65535),
        "LDAP_USE_SSL": _ldap_wizard_truthy(data.get("LDAP_USE_SSL")),
        "LDAP_USE_TLS": _ldap_wizard_truthy(data.get("LDAP_USE_TLS")),
        "LDAP_BIND_DN": (data.get("LDAP_BIND_DN") or "").strip(),
        "LDAP_BIND_PASSWORD": data.get("LDAP_BIND_PASSWORD") or "",
        "LDAP_BASE_DN": (data.get("LDAP_BASE_DN") or "").strip(),
        "LDAP_USER_DN": (data.get("LDAP_USER_DN") or "").strip(),
        "LDAP_USER_OBJECT_CLASS": (data.get("LDAP_USER_OBJECT_CLASS") or "inetOrgPerson").strip() or "inetOrgPerson",
        "LDAP_USER_LOGIN_ATTR": (data.get("LDAP_USER_LOGIN_ATTR") or "uid").strip() or "uid",
        "LDAP_USER_EMAIL_ATTR": (data.get("LDAP_USER_EMAIL_ATTR") or "mail").strip() or "mail",
        "LDAP_USER_FNAME_ATTR": (data.get("LDAP_USER_FNAME_ATTR") or "givenName").strip() or "givenName",
        "LDAP_USER_LNAME_ATTR": (data.get("LDAP_USER_LNAME_ATTR") or "sn").strip() or "sn",
        "LDAP_GROUP_DN": (data.get("LDAP_GROUP_DN") or "").strip(),
        "LDAP_GROUP_OBJECT_CLASS": (data.get("LDAP_GROUP_OBJECT_CLASS") or "groupOfNames").strip() or "groupOfNames",
        "LDAP_ADMIN_GROUP": (data.get("LDAP_ADMIN_GROUP") or "").strip(),
        "LDAP_REQUIRED_GROUP": (data.get("LDAP_REQUIRED_GROUP") or "").strip(),
        "LDAP_TLS_CA_CERT_FILE": (data.get("LDAP_TLS_CA_CERT_FILE") or "").strip(),
        "LDAP_TIMEOUT": _ldap_wizard_int(data.get("LDAP_TIMEOUT"), 10, lo=1, hi=120),
    }


def _ldap_wizard_escape_env_value(value: object) -> str:
    s = "" if value is None else str(value)
    if " " in s or "#" in s or "$" in s or "\n" in s:
        escaped = s.replace("\\", "\\\\").replace('"', '\\"')
        return f'"{escaped}"'
    return s


@admin_bp.route("/admin/ldap/setup-wizard")
@login_required
@admin_or_permission_required("manage_settings")
def ldap_setup_wizard():
    """Guided LDAP setup wizard (env-based configuration)."""
    from app.config import Config

    auth_method = getattr(Config, "AUTH_METHOD", "local")
    bind_secret = getattr(Config, "LDAP_BIND_PASSWORD", None) or ""
    current_config = {
        "auth_method": auth_method,
        "LDAP_HOST": getattr(Config, "LDAP_HOST", "") or "",
        "LDAP_PORT": getattr(Config, "LDAP_PORT", 389),
        "LDAP_USE_SSL": bool(getattr(Config, "LDAP_USE_SSL", False)),
        "LDAP_USE_TLS": bool(getattr(Config, "LDAP_USE_TLS", False)),
        "LDAP_BIND_DN": getattr(Config, "LDAP_BIND_DN", "") or "",
        "bind_password_set": bool(bind_secret),
        "LDAP_BASE_DN": getattr(Config, "LDAP_BASE_DN", "") or "",
        "LDAP_USER_DN": getattr(Config, "LDAP_USER_DN", "") or "",
        "LDAP_USER_OBJECT_CLASS": getattr(Config, "LDAP_USER_OBJECT_CLASS", "") or "inetOrgPerson",
        "LDAP_USER_LOGIN_ATTR": getattr(Config, "LDAP_USER_LOGIN_ATTR", "") or "uid",
        "LDAP_USER_EMAIL_ATTR": getattr(Config, "LDAP_USER_EMAIL_ATTR", "") or "mail",
        "LDAP_USER_FNAME_ATTR": getattr(Config, "LDAP_USER_FNAME_ATTR", "") or "givenName",
        "LDAP_USER_LNAME_ATTR": getattr(Config, "LDAP_USER_LNAME_ATTR", "") or "sn",
        "LDAP_GROUP_DN": getattr(Config, "LDAP_GROUP_DN", "") or "",
        "LDAP_GROUP_OBJECT_CLASS": getattr(Config, "LDAP_GROUP_OBJECT_CLASS", "") or "groupOfNames",
        "LDAP_ADMIN_GROUP": getattr(Config, "LDAP_ADMIN_GROUP", "") or "",
        "LDAP_REQUIRED_GROUP": getattr(Config, "LDAP_REQUIRED_GROUP", "") or "",
        "LDAP_TLS_CA_CERT_FILE": getattr(Config, "LDAP_TLS_CA_CERT_FILE", "") or "",
        "LDAP_TIMEOUT": getattr(Config, "LDAP_TIMEOUT", 10),
    }
    return render_template("admin/ldap_setup_wizard.html", current_config=current_config)


@admin_bp.route("/admin/ldap/setup-wizard/test-connection", methods=["POST"])
@limiter.limit("10 per minute")
@login_required
@admin_or_permission_required("manage_settings")
def ldap_wizard_test_connection():
    """Test LDAP bind and user subtree using wizard-submitted values."""
    from app.services.ldap_service import LDAPService

    data = request.get_json() or {}
    cfg = _ldap_wizard_cfg_from_json(data)
    result = LDAPService.test_connection(cfg)
    return jsonify(result), 200


@admin_bp.route("/admin/ldap/setup-wizard/validate-config", methods=["POST"])
@limiter.limit("20 per minute")
@login_required
@admin_or_permission_required("manage_settings")
def ldap_wizard_validate_config():
    """Validate LDAP wizard fields before generating env output."""
    data = request.get_json() or {}
    errors = []

    host = (data.get("LDAP_HOST") or "").strip()
    if not host:
        errors.append({"field": "LDAP_HOST", "message": "LDAP host is required"})

    bind_dn = (data.get("LDAP_BIND_DN") or "").strip()
    if not bind_dn:
        errors.append({"field": "LDAP_BIND_DN", "message": "Bind DN is required"})

    bind_pw = data.get("LDAP_BIND_PASSWORD")
    if bind_pw is None or str(bind_pw).strip() == "":
        errors.append({"field": "LDAP_BIND_PASSWORD", "message": "Bind password is required"})

    base_dn = (data.get("LDAP_BASE_DN") or "").strip()
    if not base_dn:
        errors.append({"field": "LDAP_BASE_DN", "message": "Base DN is required"})

    login_attr = (data.get("LDAP_USER_LOGIN_ATTR") or "").strip()
    if not login_attr:
        errors.append({"field": "LDAP_USER_LOGIN_ATTR", "message": "Login attribute is required"})

    auth_method = normalize_auth_method(data.get("AUTH_METHOD", ""))
    if not auth_includes_ldap(auth_method):
        errors.append(
            {
                "field": "AUTH_METHOD",
                "message": "Authentication method must be 'ldap' or 'all'",
            }
        )

    port_raw = data.get("LDAP_PORT")
    if port_raw not in (None, ""):
        try:
            p = int(port_raw)
            if p < 1 or p > 65535:
                errors.append({"field": "LDAP_PORT", "message": "Port must be between 1 and 65535"})
        except (TypeError, ValueError):
            errors.append({"field": "LDAP_PORT", "message": "Port must be a number"})

    timeout_raw = data.get("LDAP_TIMEOUT")
    if timeout_raw not in (None, ""):
        try:
            t = int(timeout_raw)
            if t < 1 or t > 120:
                errors.append({"field": "LDAP_TIMEOUT", "message": "Timeout must be between 1 and 120 seconds"})
        except (TypeError, ValueError):
            errors.append({"field": "LDAP_TIMEOUT", "message": "Timeout must be a number"})

    if errors:
        return jsonify({"valid": False, "errors": errors}), 200

    return jsonify({"valid": True, "errors": []}), 200


@admin_bp.route("/admin/ldap/setup-wizard/generate-config", methods=["POST"])
@limiter.limit("10 per minute")
@login_required
@admin_or_permission_required("manage_settings")
def ldap_wizard_generate_config():
    """Generate .env and docker-compose style lines from wizard data."""
    data = request.get_json() or {}

    auth_method = normalize_auth_method(data.get("AUTH_METHOD", "ldap"))
    if not auth_includes_ldap(auth_method):
        return jsonify({"success": False, "error": "AUTH_METHOD must be 'ldap' or 'all'"}), 400

    env_vars: dict[str, str] = {
        "AUTH_METHOD": auth_method,
        "LDAP_HOST": (data.get("LDAP_HOST") or "").strip(),
        "LDAP_PORT": str(_ldap_wizard_int(data.get("LDAP_PORT"), 389, lo=1, hi=65535)),
        "LDAP_USE_SSL": "true" if _ldap_wizard_truthy(data.get("LDAP_USE_SSL")) else "false",
        "LDAP_USE_TLS": "true" if _ldap_wizard_truthy(data.get("LDAP_USE_TLS")) else "false",
        "LDAP_BIND_DN": (data.get("LDAP_BIND_DN") or "").strip(),
        "LDAP_BIND_PASSWORD": str(data.get("LDAP_BIND_PASSWORD") or ""),
        "LDAP_BASE_DN": (data.get("LDAP_BASE_DN") or "").strip(),
        "LDAP_USER_DN": (data.get("LDAP_USER_DN") or "").strip(),
        "LDAP_USER_OBJECT_CLASS": (data.get("LDAP_USER_OBJECT_CLASS") or "inetOrgPerson").strip() or "inetOrgPerson",
        "LDAP_USER_LOGIN_ATTR": (data.get("LDAP_USER_LOGIN_ATTR") or "uid").strip() or "uid",
        "LDAP_USER_EMAIL_ATTR": (data.get("LDAP_USER_EMAIL_ATTR") or "mail").strip() or "mail",
        "LDAP_USER_FNAME_ATTR": (data.get("LDAP_USER_FNAME_ATTR") or "givenName").strip() or "givenName",
        "LDAP_USER_LNAME_ATTR": (data.get("LDAP_USER_LNAME_ATTR") or "sn").strip() or "sn",
        "LDAP_GROUP_DN": (data.get("LDAP_GROUP_DN") or "").strip(),
        "LDAP_GROUP_OBJECT_CLASS": (data.get("LDAP_GROUP_OBJECT_CLASS") or "groupOfNames").strip() or "groupOfNames",
        "LDAP_ADMIN_GROUP": (data.get("LDAP_ADMIN_GROUP") or "").strip(),
        "LDAP_REQUIRED_GROUP": (data.get("LDAP_REQUIRED_GROUP") or "").strip(),
        "LDAP_TLS_CA_CERT_FILE": (data.get("LDAP_TLS_CA_CERT_FILE") or "").strip(),
        "LDAP_TIMEOUT": str(_ldap_wizard_int(data.get("LDAP_TIMEOUT"), 10, lo=1, hi=120)),
    }

    for req_key in ("LDAP_HOST", "LDAP_BIND_DN", "LDAP_BIND_PASSWORD", "LDAP_BASE_DN"):
        if not env_vars.get(req_key, "").strip():
            return jsonify({"success": False, "error": f"{req_key} is required"}), 400

    optional_skip_if_empty = frozenset(
        {"LDAP_ADMIN_GROUP", "LDAP_REQUIRED_GROUP", "LDAP_TLS_CA_CERT_FILE", "LDAP_USER_DN"}
    )

    env_lines = []
    for key, value in env_vars.items():
        v = str(value)
        if not v.strip() and key in optional_skip_if_empty:
            continue
        escaped = _ldap_wizard_escape_env_value(v)
        env_lines.append(f"{key}={escaped}")

    env_content = "\n".join(env_lines)

    docker_compose_lines = ["      # LDAP configuration"]
    for key, value in env_vars.items():
        v = str(value)
        if not v.strip() and key in optional_skip_if_empty:
            continue
        dv = _ldap_wizard_escape_env_value(v)
        docker_compose_lines.append(f"      - {key}={dv}")

    docker_compose_content = "\n".join(docker_compose_lines)

    return (
        jsonify(
            {
                "success": True,
                "env_content": env_content,
                "docker_compose_content": docker_compose_content,
            }
        ),
        200,
    )


# ==================== API Token Management ====================


@admin_bp.route("/admin/api-tokens")
@login_required
@admin_or_permission_required("manage_api_tokens")
def api_tokens():
    """API tokens management page"""
    from app.models import ApiToken

    tokens = ApiToken.query.order_by(ApiToken.created_at.desc()).all()
    users = User.query.filter_by(is_active=True).order_by(User.username).all()

    return render_template("admin/api_tokens.html", tokens=tokens, users=users, now=datetime.utcnow())


@admin_bp.route("/admin/api-tokens", methods=["POST"])
@login_required
@admin_or_permission_required("manage_api_tokens")
def create_api_token():
    """Create a new API token"""
    from app.models import ApiToken

    data = request.get_json() or {}

    # Validate input
    if not data.get("name"):
        return jsonify({"error": "Token name is required"}), 400
    if not data.get("user_id"):
        return jsonify({"error": "User ID is required"}), 400
    if not data.get("scopes"):
        return jsonify({"error": "At least one scope is required"}), 400

    # Verify user exists
    user = User.query.get(data["user_id"])
    if not user:
        return jsonify({"error": "User not found"}), 404
    if not user:
        return jsonify({"error": "Invalid user"}), 400

    # Create token
    try:
        api_token, plain_token = ApiToken.create_token(
            user_id=data["user_id"],
            name=data["name"],
            description=data.get("description", ""),
            scopes=data["scopes"],
            expires_days=data.get("expires_days"),
        )

        db.session.add(api_token)
        db.session.commit()

        current_app.logger.info(
            f"API token '{data['name']}' created for user {user.username} by {current_user.username}"
        )

        return (
            jsonify({"message": "API token created successfully", "token": plain_token, "token_id": api_token.id}),
            201,
        )

    except Exception as e:
        db.session.rollback()
        current_app.logger.error(f"Failed to create API token: {e}")
        return jsonify({"error": "Failed to create token"}), 500


@admin_bp.route("/admin/api-tokens/<int:token_id>/toggle", methods=["POST"])
@login_required
@admin_or_permission_required("manage_api_tokens")
def toggle_api_token(token_id):
    """Toggle API token active status"""
    from app.models import ApiToken

    token = ApiToken.query.get_or_404(token_id)
    token.is_active = not token.is_active

    try:
        db.session.commit()
        status = "activated" if token.is_active else "deactivated"
        current_app.logger.info(f"API token '{token.name}' {status} by {current_user.username}")
        return jsonify({"message": f"Token {status} successfully"})
    except Exception as e:
        db.session.rollback()
        current_app.logger.error(f"Failed to toggle API token: {e}")
        return jsonify({"error": "Failed to update token"}), 500


@admin_bp.route("/admin/api-tokens/<int:token_id>", methods=["DELETE"])
@login_required
@admin_or_permission_required("manage_api_tokens")
def delete_api_token(token_id):
    """Delete an API token"""
    from app.models import ApiToken

    token = ApiToken.query.get_or_404(token_id)
    token_name = token.name

    try:
        db.session.delete(token)
        db.session.commit()
        current_app.logger.info(f"API token '{token_name}' deleted by {current_user.username}")
        return jsonify({"message": "Token deleted successfully"})
    except Exception as e:
        db.session.rollback()
        current_app.logger.error(f"Failed to delete API token: {e}")
        return jsonify({"error": "Failed to delete token"}), 500


# ==================== Email Configuration Management ====================


@admin_bp.route("/admin/email")
@login_required
@admin_or_permission_required("manage_settings")
def email_support():
    """Email configuration and testing page"""
    from app.utils.email import check_email_configuration

    # Get email configuration status
    email_status = check_email_configuration()

    # Log dashboard access
    app_module.log_event("admin.email_support_viewed", user_id=current_user.id)
    app_module.track_event(current_user.id, "admin.email_support_viewed", {})

    return render_template("admin/email_support.html", email_status=email_status)


@admin_bp.route("/admin/email/test", methods=["POST"])
@limiter.limit("5 per minute")
@login_required
@admin_or_permission_required("manage_settings")
def test_email():
    """Send a test email"""
    from app.utils.email import send_test_email

    data = request.get_json() or {}
    recipient = data.get("recipient")

    if not recipient:
        current_app.logger.warning(f"[EMAIL TEST API] No recipient provided by user {current_user.username}")
        return jsonify({"success": False, "message": "Recipient email is required"}), 400

    current_app.logger.info(f"[EMAIL TEST API] Test email request from user {current_user.username} to {recipient}")

    # Send test email
    sender_name = current_user.username or "TimeTracker Admin"
    success, message = send_test_email(recipient, sender_name)

    # Log the test
    current_app.logger.info(f"[EMAIL TEST API] Result: {'SUCCESS' if success else 'FAILED'} - {message}")
    app_module.log_event("admin.email_test_sent", user_id=current_user.id, recipient=recipient, success=success)
    app_module.track_event(current_user.id, "admin.email_test_sent", {"success": success, "configured": success})

    if success:
        return jsonify({"success": True, "message": message}), 200
    else:
        return jsonify({"success": False, "message": message}), 500


@admin_bp.route("/admin/email/config-status", methods=["GET"])
@login_required
@admin_or_permission_required("manage_settings")
def email_config_status():
    """Get current email configuration status (for AJAX polling)"""
    from app.utils.email import check_email_configuration

    email_status = check_email_configuration()
    return jsonify(email_status), 200


@admin_bp.route("/admin/email/configure", methods=["POST"])
@limiter.limit("10 per minute")
@login_required
@admin_or_permission_required("manage_settings")
def save_email_config():
    """Save email configuration to database"""
    from app.utils.email import reload_mail_config

    data = request.get_json() or {}

    current_app.logger.info(f"[EMAIL CONFIG] Saving email configuration by user {current_user.username}")

    # Get settings
    settings = Settings.get_settings()

    # Update email configuration
    settings.mail_enabled = data.get("enabled", False)
    settings.mail_server = data.get("server", "").strip()
    settings.mail_port = int(data.get("port", 587))
    settings.mail_use_tls = data.get("use_tls", True)
    settings.mail_use_ssl = data.get("use_ssl", False)
    settings.mail_username = data.get("username", "").strip()

    # Only update password if provided (non-empty)
    password = data.get("password", "").strip()
    if password:
        settings.set_secret("mail_password", password)
        current_app.logger.info("[EMAIL CONFIG] Password updated")

    settings.mail_default_sender = data.get("default_sender", "").strip()
    test_recipient = data.get("test_recipient", "").strip()
    if test_recipient and "@" not in test_recipient:
        return jsonify({"success": False, "message": "Invalid test recipient email address"}), 400
    settings.mail_test_recipient = test_recipient

    current_app.logger.info(
        f"[EMAIL CONFIG] Settings: enabled={settings.mail_enabled}, "
        f"server={settings.mail_server}:{settings.mail_port}, "
        f"tls={settings.mail_use_tls}, ssl={settings.mail_use_ssl}"
    )

    # Validate
    if settings.mail_enabled and not settings.mail_server:
        current_app.logger.warning("[EMAIL CONFIG] Validation failed: mail server required")
        return jsonify({"success": False, "message": "Mail server is required when email is enabled"}), 400

    if settings.mail_use_tls and settings.mail_use_ssl:
        current_app.logger.warning("[EMAIL CONFIG] Validation failed: both TLS and SSL enabled")
        return jsonify({"success": False, "message": "Cannot use both TLS and SSL. Please choose one."}), 400

    # Save to database
    if not safe_commit("admin_save_email_config"):
        current_app.logger.error("[EMAIL CONFIG] Failed to save to database")
        return jsonify({"success": False, "message": "Failed to save email configuration to database"}), 500

    current_app.logger.info("[EMAIL CONFIG] ✓ Configuration saved to database")

    # Reload mail configuration
    if settings.mail_enabled:
        current_app.logger.info("[EMAIL CONFIG] Reloading mail configuration...")
        reload_result = reload_mail_config(current_app._get_current_object())
        current_app.logger.info(f"[EMAIL CONFIG] Mail config reload: {'SUCCESS' if reload_result else 'FAILED'}")

    # Log the change
    app_module.log_event("admin.email_config_saved", user_id=current_user.id, enabled=settings.mail_enabled)
    app_module.track_event(
        current_user.id, "admin.email_config_saved", {"enabled": settings.mail_enabled, "source": "database"}
    )

    current_app.logger.info("[EMAIL CONFIG] ✓ Email configuration update complete")

    return jsonify({"success": True, "message": "Email configuration saved successfully"}), 200


@admin_bp.route("/admin/email/get-config", methods=["GET"])
@login_required
@admin_or_permission_required("manage_settings")
def get_email_config():
    """Get current email configuration from database"""
    settings = Settings.get_settings()

    return (
        jsonify(
            {
                "enabled": settings.mail_enabled,
                "server": settings.mail_server or "",
                "port": settings.mail_port or 587,
                "use_tls": settings.mail_use_tls if settings.mail_use_tls is not None else True,
                "use_ssl": settings.mail_use_ssl if settings.mail_use_ssl is not None else False,
                "username": settings.mail_username or "",
                "password_set": bool(settings.mail_password),
                "default_sender": settings.mail_default_sender or "",
                "test_recipient": (getattr(settings, "mail_test_recipient", None) or ""),
            }
        ),
        200,
    )


# ==================== Email Template Management ====================


@admin_bp.route("/admin/email-templates")
@login_required
@admin_or_permission_required("manage_settings")
def list_email_templates():
    """List all email templates"""
    from app.models import InvoiceTemplate

    templates = InvoiceTemplate.query.order_by(InvoiceTemplate.name).all()

    return render_template("admin/email_templates/list.html", templates=templates)


@admin_bp.route("/admin/email-templates/create", methods=["GET", "POST"])
@login_required
@admin_or_permission_required("manage_settings")
def create_email_template():
    """Create a new email template"""
    from app.models import InvoiceTemplate

    if request.method == "POST":
        name = request.form.get("name", "").strip()
        description = request.form.get("description", "").strip()
        html = request.form.get("html", "").strip()
        css = request.form.get("css", "").strip()
        is_default = request.form.get("is_default") == "on"

        # Validate
        if not name:
            flash(_("Template name is required"), "error")
            return render_template(
                "admin/email_templates/create.html", name=name, description=description, html=html, css=css
            )

        if not html:
            flash(_("HTML template content is required"), "error")
            return render_template(
                "admin/email_templates/create.html", name=name, description=description, html=html, css=css
            )

        # Check for duplicate name
        existing = InvoiceTemplate.query.filter_by(name=name).first()
        if existing:
            flash(_("A template with this name already exists"), "error")
            return render_template(
                "admin/email_templates/create.html", name=name, description=description, html=html, css=css
            )

        # If setting as default, unset other defaults
        if is_default:
            InvoiceTemplate.query.update({InvoiceTemplate.is_default: False})

        # Create template
        template = InvoiceTemplate(
            name=name,
            description=description if description else None,
            html=html if html else None,
            css=css if css else None,
            is_default=is_default,
        )

        db.session.add(template)
        if not safe_commit("create_email_template", {"name": name}):
            flash(_("Could not create email template due to a database error."), "error")
            return render_template(
                "admin/email_templates/create.html", name=name, description=description, html=html, css=css
            )

        flash(_("Email template created successfully"), "success")
        return redirect(url_for("admin.list_email_templates"))

    return render_template("admin/email_templates/create.html")


@admin_bp.route("/admin/email-templates/<int:template_id>/send-test", methods=["POST"])
@limiter.limit("5 per minute")
@login_required
@admin_or_permission_required("manage_settings")
def send_email_template_test(template_id):
    """Send a test email using a saved invoice email template."""
    from app.models import Settings
    from app.utils.email import send_invoice_template_test_email

    data = request.get_json() or {}
    recipient = (data.get("recipient") or "").strip()
    if not recipient:
        settings = Settings.get_settings()
        recipient = (getattr(settings, "mail_test_recipient", None) or "").strip()
    if not recipient:
        return jsonify({"success": False, "message": "Recipient email is required"}), 400

    invoice_id = data.get("invoice_id")
    if invoice_id is not None and invoice_id != "":
        try:
            invoice_id = int(invoice_id)
        except (TypeError, ValueError):
            return jsonify({"success": False, "message": "Invalid invoice_id"}), 400
    else:
        invoice_id = None

    custom_message = data.get("custom_message")
    if custom_message is not None:
        custom_message = str(custom_message).strip() or None

    success, message = send_invoice_template_test_email(
        template_id, recipient, invoice_id=invoice_id, custom_message=custom_message
    )

    if success:
        return jsonify({"success": True, "message": message}), 200
    return jsonify({"success": False, "message": message}), 500


@admin_bp.route("/admin/email-templates/<int:template_id>")
@login_required
@admin_or_permission_required("manage_settings")
def view_email_template(template_id):
    """View email template details"""
    from app.models import InvoiceTemplate

    template = InvoiceTemplate.query.get_or_404(template_id)

    return render_template("admin/email_templates/view.html", template=template)


@admin_bp.route("/admin/email-templates/<int:template_id>/edit", methods=["GET", "POST"])
@login_required
@admin_or_permission_required("manage_settings")
def edit_email_template(template_id):
    """Edit email template"""
    from app.models import InvoiceTemplate

    template = InvoiceTemplate.query.get_or_404(template_id)

    if request.method == "POST":
        name = request.form.get("name", "").strip()
        description = request.form.get("description", "").strip()
        html = request.form.get("html", "").strip()
        css = request.form.get("css", "").strip()
        is_default = request.form.get("is_default") == "on"

        # Validate
        if not name:
            flash(_("Template name is required"), "error")
            return render_template("admin/email_templates/edit.html", template=template)

        # Check for duplicate name (excluding current template)
        existing = InvoiceTemplate.query.filter(InvoiceTemplate.name == name, InvoiceTemplate.id != template_id).first()
        if existing:
            flash(_("A template with this name already exists"), "error")
            return render_template("admin/email_templates/edit.html", template=template)

        # If setting as default, unset other defaults
        if is_default:
            InvoiceTemplate.query.filter(InvoiceTemplate.id != template_id).update({InvoiceTemplate.is_default: False})

        # Update template
        template.name = name
        template.description = description if description else None
        template.html = html if html else None
        template.css = css if css else None
        template.is_default = is_default
        template.updated_at = datetime.utcnow()

        if not safe_commit("edit_email_template", {"template_id": template_id}):
            flash(_("Could not update email template due to a database error."), "error")
            return render_template("admin/email_templates/edit.html", template=template)

        flash(_("Email template updated successfully"), "success")
        return redirect(url_for("admin.view_email_template", template_id=template_id))

    return render_template("admin/email_templates/edit.html", template=template)


@admin_bp.route("/admin/email-templates/<int:template_id>/delete", methods=["POST"])
@login_required
@admin_or_permission_required("manage_settings")
def delete_email_template(template_id):
    """Delete email template"""
    from app.models import InvoiceTemplate

    template = InvoiceTemplate.query.get_or_404(template_id)
    template_name = template.name

    # Check if template is in use
    if template.invoices.count() > 0 or template.recurring_invoices.count() > 0:
        flash(_("Cannot delete template that is in use by invoices or recurring invoices"), "error")
        return redirect(url_for("admin.list_email_templates"))

    db.session.delete(template)
    if not safe_commit("delete_email_template", {"template_id": template_id}):
        flash(_("Could not delete email template due to a database error."), "error")
    else:
        flash(_('Email template "%(name)s" deleted successfully', name=template_name), "success")

    return redirect(url_for("admin.list_email_templates"))


# ==================== Integration Setup Routes ====================


@admin_bp.route("/admin/integrations")
@login_required
@admin_or_permission_required("manage_integrations")
def list_integrations_admin():
    """List all integrations (admin view). Redirect to main integrations page."""
    return redirect(url_for("integrations.list_integrations"))


@admin_bp.route("/admin/integrations/<provider>/setup", methods=["GET", "POST"])
@login_required
@admin_or_permission_required("manage_integrations")
def integration_setup(provider):
    """Setup page for configuring integration OAuth credentials. Redirect to main integrations manage page."""
    return redirect(url_for("integrations.manage_integration", provider=provider))
