# createsonline/admin/dynamic_admin.py
"""
CREATESONLINE Dynamic Admin Panel

Fully automatic admin panel — like Django Admin — that auto-discovers
SQLAlchemy models and generates complete CRUD interfaces dynamically.
No hardcoded templates. Everything is generated from model introspection.

Usage::

    from createsonline.admin.dynamic_admin import DynamicAdmin

    admin = DynamicAdmin(db_session_factory)
    
    # Auto-register a model (or it auto-discovers all models)
    admin.register(User)
    admin.register(Product, config=ModelConfig(
        list_display=['name', 'price', 'category'],
        search_fields=['name', 'description'],
        list_filter=['category'],
    ))
    
    # Get routes to mount in your app
    routes = admin.get_routes()
"""

import json
import time
import html as html_mod
from typing import Any, Callable, Dict, List, Optional, Type, Union
from datetime import datetime

try:
    from sqlalchemy import inspect as sql_inspect
    from sqlalchemy.orm import Session, sessionmaker
    from sqlalchemy import func, or_
    HAS_SQLALCHEMY = True
except ImportError:
    HAS_SQLALCHEMY = False


# ============================================================
# Configuration
# ============================================================

class ModelConfig:
    """
    Configuration for how a model is displayed in the admin.
    Like Django's ModelAdmin but simpler.
    """
    
    def __init__(
        self,
        list_display: List[str] = None,
        list_display_links: List[str] = None,
        list_filter: List[str] = None,
        search_fields: List[str] = None,
        ordering: List[str] = None,
        fields: List[str] = None,
        exclude: List[str] = None,
        readonly_fields: List[str] = None,
        fieldsets: List[tuple] = None,
        list_per_page: int = 25,
        actions: List[str] = None,
        form_widgets: Dict[str, str] = None,
        verbose_name: str = None,
        verbose_name_plural: str = None,
        icon: str = None,
    ):
        self.list_display = list_display
        self.list_display_links = list_display_links
        self.list_filter = list_filter or []
        self.search_fields = search_fields or []
        self.ordering = ordering
        self.fields = fields
        self.exclude = exclude or []
        self.readonly_fields = readonly_fields or []
        self.fieldsets = fieldsets
        self.list_per_page = list_per_page
        self.actions = actions or ['delete']
        self.form_widgets = form_widgets or {}
        self.verbose_name = verbose_name
        self.verbose_name_plural = verbose_name_plural
        self.icon = icon or '📋'


# ============================================================
# Model Introspection
# ============================================================

class ModelMeta:
    """Introspected metadata about a SQLAlchemy model."""
    
    def __init__(self, model_class, config: ModelConfig = None):
        self.model_class = model_class
        self.config = config or ModelConfig()
        self.name = model_class.__name__
        self.table_name = getattr(model_class, '__tablename__', self.name.lower())
        self.verbose_name = self.config.verbose_name or self.name.replace('_', ' ').title()
        self.verbose_name_plural = self.config.verbose_name_plural or self._pluralize(self.verbose_name)
        self.fields = self._inspect_fields()
        self.pk_field = self._find_pk()
        self.relationships = self._inspect_relationships()
        
        # Auto-fill config defaults
        if self.config.list_display is None:
            self.config.list_display = [f['name'] for f in self.fields[:6]]
        if self.config.list_display_links is None and self.config.list_display:
            self.config.list_display_links = [self.config.list_display[0]]
        if not self.config.search_fields:
            self.config.search_fields = [
                f['name'] for f in self.fields
                if f['type'] in ('text', 'textarea', 'email', 'url') and not f['primary_key']
            ][:3]
    
    def _inspect_fields(self) -> List[dict]:
        """Extract field info from SQLAlchemy model."""
        fields = []
        try:
            mapper = sql_inspect(self.model_class)
            for col in mapper.columns:
                col_type = type(col.type).__name__
                
                # Map SQLAlchemy types to HTML input types
                html_type = self._column_to_html_type(col, col_type)
                
                field = {
                    'name': col.name,
                    'label': col.name.replace('_', ' ').title(),
                    'type': html_type,
                    'column_type': col_type,
                    'required': not col.nullable and col.default is None and not col.primary_key,
                    'primary_key': col.primary_key,
                    'unique': col.unique or False,
                    'nullable': col.nullable,
                    'max_length': getattr(col.type, 'length', None),
                    'default': str(col.default.arg) if col.default and hasattr(col.default, 'arg') else None,
                    'comment': col.comment or '',
                    'autoincrement': getattr(col, 'autoincrement', False),
                    'foreign_key': bool(col.foreign_keys),
                }
                fields.append(field)
        except Exception:
            pass
        return fields
    
    @staticmethod
    def _column_to_html_type(col, col_type: str) -> str:
        """Map SQLAlchemy column to HTML input type."""
        name_lower = col.name.lower()
        
        # Name-based detection
        if 'password' in name_lower:
            return 'password'
        if 'email' in name_lower:
            return 'email'
        if 'url' in name_lower or 'link' in name_lower or 'website' in name_lower:
            return 'url'
        if 'phone' in name_lower or 'tel' in name_lower:
            return 'tel'
        if 'color' in name_lower or 'colour' in name_lower:
            return 'color'
        if 'image' in name_lower or 'photo' in name_lower or 'avatar' in name_lower:
            return 'file'
        
        # Type-based mapping
        type_map = {
            'Integer': 'number', 'BigInteger': 'number', 'SmallInteger': 'number',
            'String': 'text', 'VARCHAR': 'text',
            'Text': 'textarea', 'UnicodeText': 'textarea', 'CLOB': 'textarea',
            'Boolean': 'checkbox', 'BOOLEAN': 'checkbox',
            'DateTime': 'datetime-local', 'TIMESTAMP': 'datetime-local',
            'Date': 'date', 'Time': 'time',
            'Float': 'number', 'Numeric': 'number', 'DECIMAL': 'number',
            'JSON': 'textarea', 'JSONB': 'textarea',
            'LargeBinary': 'file', 'BLOB': 'file',
            'Enum': 'select',
        }
        
        # Check if String with large length → textarea
        if col_type in ('String', 'VARCHAR'):
            length = getattr(col.type, 'length', None)
            if length and length > 255:
                return 'textarea'
        
        return type_map.get(col_type, 'text')
    
    def _find_pk(self) -> Optional[str]:
        for f in self.fields:
            if f['primary_key']:
                return f['name']
        return None
    
    def _inspect_relationships(self) -> List[dict]:
        relationships = []
        try:
            mapper = sql_inspect(self.model_class)
            for rel in mapper.relationships:
                relationships.append({
                    'name': rel.key,
                    'target': rel.mapper.class_.__name__,
                    'type': rel.direction.name,
                    'uselist': rel.uselist,
                })
        except Exception:
            pass
        return relationships
    
    @staticmethod
    def _pluralize(name: str) -> str:
        if name.endswith('s') or name.endswith('x') or name.endswith('z'):
            return name + 'es'
        if name.endswith('y') and name[-2:] not in ('ay', 'ey', 'oy', 'uy'):
            return name[:-1] + 'ies'
        return name + 's'
    
    def get_form_fields(self) -> List[dict]:
        """Get fields for the create/edit form (excludes PK and auto fields)."""
        excluded = set(self.config.exclude)
        return [
            f for f in self.fields
            if f['name'] not in excluded
            and not (f['primary_key'] and f.get('autoincrement'))
        ]
    
    def get_list_fields(self) -> List[dict]:
        """Get fields for the list view."""
        display_names = self.config.list_display
        return [f for f in self.fields if f['name'] in display_names]


# ============================================================
# HTML Generator (no hardcoded templates — all dynamic)
# ============================================================

class AdminHTMLGenerator:
    """Generates all admin HTML dynamically from model metadata."""
    
    CSS = '''
    :root {
        --primary: #2563eb; --primary-dark: #1d4ed8; --danger: #dc2626;
        --success: #16a34a; --warning: #d97706; --bg: #f8fafc;
        --surface: #fff; --text: #1e293b; --text-light: #64748b;
        --border: #e2e8f0; --radius: 8px; --shadow: 0 1px 3px rgba(0,0,0,.1);
    }
    * { box-sizing: border-box; margin: 0; padding: 0; }
    body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
           background: var(--bg); color: var(--text); line-height: 1.6; }
    a { color: var(--primary); text-decoration: none; }
    a:hover { text-decoration: underline; }
    
    .admin-nav { background: var(--text); color: #fff; padding: 0 2rem;
                 display: flex; align-items: center; height: 56px;
                 box-shadow: 0 2px 8px rgba(0,0,0,.2); }
    .admin-nav h1 { font-size: 1.2rem; font-weight: 600; }
    .admin-nav a { color: #94a3b8; margin-left: 2rem; font-size: .9rem; }
    .admin-nav a:hover { color: #fff; text-decoration: none; }
    
    .admin-content { max-width: 1200px; margin: 2rem auto; padding: 0 1rem; }
    
    .card { background: var(--surface); border-radius: var(--radius);
            box-shadow: var(--shadow); padding: 1.5rem; margin-bottom: 1.5rem; }
    .card h2 { font-size: 1.25rem; margin-bottom: 1rem; display: flex;
               align-items: center; gap: .5rem; }
    
    .model-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
                  gap: 1rem; }
    .model-card { background: var(--surface); border-radius: var(--radius);
                  box-shadow: var(--shadow); padding: 1.25rem;
                  transition: transform .15s, box-shadow .15s; border-left: 4px solid var(--primary); }
    .model-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,.15); }
    .model-card h3 { font-size: 1rem; margin-bottom: .25rem; }
    .model-card .meta { font-size: .85rem; color: var(--text-light); }
    .model-card .actions { margin-top: .75rem; display: flex; gap: .5rem; }
    
    .btn { display: inline-flex; align-items: center; gap: .4rem; padding: .5rem 1rem;
           border-radius: var(--radius); font-size: .875rem; font-weight: 500;
           border: none; cursor: pointer; transition: background .15s; }
    .btn-primary { background: var(--primary); color: #fff; }
    .btn-primary:hover { background: var(--primary-dark); text-decoration: none; }
    .btn-danger { background: var(--danger); color: #fff; }
    .btn-danger:hover { background: #b91c1c; }
    .btn-outline { background: transparent; border: 1px solid var(--border); color: var(--text); }
    .btn-outline:hover { border-color: var(--primary); color: var(--primary); }
    .btn-sm { padding: .3rem .7rem; font-size: .8rem; }
    
    table { width: 100%; border-collapse: collapse; }
    th { text-align: left; padding: .75rem; font-size: .8rem; font-weight: 600;
         text-transform: uppercase; letter-spacing: .05em; color: var(--text-light);
         border-bottom: 2px solid var(--border); }
    td { padding: .75rem; border-bottom: 1px solid var(--border); font-size: .9rem; }
    tr:hover { background: #f1f5f9; }
    
    .form-group { margin-bottom: 1.25rem; }
    .form-group label { display: block; font-weight: 500; margin-bottom: .3rem; font-size: .9rem; }
    .form-group .help { font-size: .8rem; color: var(--text-light); margin-top: .2rem; }
    .form-control { width: 100%; padding: .6rem .8rem; border: 1px solid var(--border);
                    border-radius: var(--radius); font-size: .9rem; transition: border-color .15s; }
    .form-control:focus { outline: none; border-color: var(--primary);
                          box-shadow: 0 0 0 3px rgba(37,99,235,.15); }
    textarea.form-control { min-height: 120px; resize: vertical; }
    input[type="checkbox"] { width: 1.1rem; height: 1.1rem; }
    select.form-control { cursor: pointer; }
    
    .toolbar { display: flex; justify-content: space-between; align-items: center;
               flex-wrap: wrap; gap: 1rem; margin-bottom: 1rem; }
    .search-box { display: flex; gap: .5rem; }
    .search-box input { min-width: 250px; }
    
    .pagination { display: flex; gap: .5rem; justify-content: center; margin-top: 1rem; }
    .pagination a, .pagination span { padding: .4rem .8rem; border: 1px solid var(--border);
                                       border-radius: var(--radius); font-size: .85rem; }
    .pagination .active { background: var(--primary); color: #fff; border-color: var(--primary); }
    
    .alert { padding: 1rem; border-radius: var(--radius); margin-bottom: 1rem; font-size: .9rem; }
    .alert-success { background: #dcfce7; color: #166534; border: 1px solid #bbf7d0; }
    .alert-danger { background: #fee2e2; color: #991b1b; border: 1px solid #fecaca; }
    .alert-info { background: #dbeafe; color: #1e40af; border: 1px solid #bfdbfe; }
    
    .breadcrumb { display: flex; gap: .5rem; font-size: .85rem; color: var(--text-light);
                  margin-bottom: 1rem; }
    .breadcrumb a { color: var(--primary); }
    .breadcrumb span::before { content: "/"; margin-right: .5rem; }
    
    .stats-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
                 gap: 1rem; margin-bottom: 1.5rem; }
    .stat-card { background: var(--surface); border-radius: var(--radius); padding: 1rem;
                 text-align: center; box-shadow: var(--shadow); }
    .stat-card .number { font-size: 1.8rem; font-weight: 700; color: var(--primary); }
    .stat-card .label { font-size: .8rem; color: var(--text-light); }

    @media (max-width: 768px) {
        .admin-content { padding: 0 .5rem; }
        .model-grid { grid-template-columns: 1fr; }
        .toolbar { flex-direction: column; }
        .search-box { width: 100%; }
        .search-box input { width: 100%; min-width: 0; }
    }
    '''
    
    @classmethod
    def _e(cls, text) -> str:
        """HTML-escape text."""
        return html_mod.escape(str(text)) if text is not None else ''
    
    @classmethod
    def base_layout(cls, title: str, content: str, nav_extra: str = '',
                    site_title: str = 'CREATESONLINE Admin') -> str:
        """Base HTML layout."""
        return f'''<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{cls._e(title)} | {cls._e(site_title)}</title>
<style>{cls.CSS}</style>
</head>
<body>
<nav class="admin-nav">
  <h1>🚀 {cls._e(site_title)}</h1>
  <a href="/admin">Dashboard</a>
  {nav_extra}
  <a href="/admin/logout" style="margin-left:auto;">Logout</a>
</nav>
<div class="admin-content">
{content}
</div>
</body>
</html>'''
    
    @classmethod
    def dashboard(cls, models: Dict[str, 'ModelMeta'], counts: Dict[str, int],
                  site_title: str = 'CREATESONLINE Admin') -> str:
        """Generate the admin dashboard/index page."""
        
        # Stats row
        total_models = len(models)
        total_records = sum(counts.values())
        stats = f'''
<div class="stats-row">
  <div class="stat-card"><div class="number">{total_models}</div><div class="label">Models</div></div>
  <div class="stat-card"><div class="number">{total_records:,}</div><div class="label">Total Records</div></div>
</div>'''
        
        # Model cards
        cards = []
        for name, meta in models.items():
            count = counts.get(name, 0)
            slug = name.lower()
            cards.append(f'''
<div class="model-card">
  <h3>{cls._e(meta.config.icon)} {cls._e(meta.verbose_name_plural)}</h3>
  <div class="meta">{count:,} record{"s" if count != 1 else ""} · {len(meta.fields)} fields</div>
  <div class="actions">
    <a class="btn btn-primary btn-sm" href="/admin/model/{slug}/">View All</a>
    <a class="btn btn-outline btn-sm" href="/admin/model/{slug}/add/">+ Add New</a>
  </div>
</div>''')
        
        content = f'''
<h2 style="margin-bottom:1rem;">Dashboard</h2>
{stats}
<h2 style="margin-bottom:1rem;">Registered Models</h2>
<div class="model-grid">{"".join(cards)}</div>
'''
        return cls.base_layout('Dashboard', content, site_title=site_title)
    
    @classmethod
    def list_view(cls, meta: 'ModelMeta', rows: List[dict], total: int,
                  page: int = 1, search: str = '', message: str = '') -> str:
        """Generate model list/changelist page."""
        slug = meta.name.lower()
        per_page = meta.config.list_per_page
        total_pages = max(1, (total + per_page - 1) // per_page)
        
        # Breadcrumb
        breadcrumb = f'''
<div class="breadcrumb">
  <a href="/admin">Dashboard</a>
  <span>{cls._e(meta.verbose_name_plural)}</span>
</div>'''
        
        # Message
        msg_html = ''
        if message:
            msg_html = f'<div class="alert alert-success">{cls._e(message)}</div>'
        
        # Toolbar
        search_html = ''
        if meta.config.search_fields:
            search_html = f'''
<form class="search-box" method="get" action="/admin/model/{slug}/">
  <input class="form-control" name="q" value="{cls._e(search)}"
         placeholder="Search {", ".join(meta.config.search_fields)}...">
  <button class="btn btn-primary" type="submit">Search</button>
</form>'''
        
        toolbar = f'''
<div class="toolbar">
  <h2>{cls._e(meta.config.icon)} {cls._e(meta.verbose_name_plural)} ({total:,})</h2>
  <div style="display:flex;gap:.5rem;">
    {search_html}
    <a class="btn btn-primary" href="/admin/model/{slug}/add/">+ Add {cls._e(meta.verbose_name)}</a>
  </div>
</div>'''
        
        # Table
        display_fields = meta.config.list_display
        link_fields = set(meta.config.list_display_links or [])
        
        ths = ''.join(f'<th>{cls._e(f.replace("_"," ").title())}</th>' for f in display_fields)
        ths += '<th style="width:120px;">Actions</th>'
        
        trs = []
        for row in rows:
            pk_val = row.get(meta.pk_field, '')
            tds = []
            for fname in display_fields:
                val = row.get(fname, '')
                cell = cls._e(val)
                if fname in link_fields:
                    cell = f'<a href="/admin/model/{slug}/{pk_val}/edit/">{cell}</a>'
                tds.append(f'<td>{cell}</td>')
            
            tds.append(f'''<td>
  <a class="btn btn-outline btn-sm" href="/admin/model/{slug}/{pk_val}/edit/">Edit</a>
  <a class="btn btn-danger btn-sm" href="/admin/model/{slug}/{pk_val}/delete/"
     onclick="return confirm('Delete this {cls._e(meta.verbose_name)}?')">Delete</a>
</td>''')
            trs.append(f'<tr>{"".join(tds)}</tr>')
        
        table = f'''
<div class="card" style="padding:0;overflow:auto;">
<table>
  <thead><tr>{ths}</tr></thead>
  <tbody>{"".join(trs) if trs else f"<tr><td colspan='{len(display_fields)+1}' style='text-align:center;padding:2rem;color:var(--text-light);'>No records found</td></tr>"}</tbody>
</table>
</div>'''
        
        # Pagination
        pagination = ''
        if total_pages > 1:
            pages = []
            for p in range(1, total_pages + 1):
                if p == page:
                    pages.append(f'<span class="active">{p}</span>')
                else:
                    q_param = f'&q={search}' if search else ''
                    pages.append(f'<a href="/admin/model/{slug}/?page={p}{q_param}">{p}</a>')
            pagination = f'<div class="pagination">{"".join(pages)}</div>'
        
        content = breadcrumb + msg_html + toolbar + table + pagination
        return cls.base_layout(meta.verbose_name_plural, content)
    
    @classmethod
    def form_view(cls, meta: 'ModelMeta', obj: dict = None,
                  errors: Dict[str, str] = None, is_edit: bool = False) -> str:
        """Generate create/edit form page."""
        slug = meta.name.lower()
        title = f'Edit {meta.verbose_name}' if is_edit else f'Add {meta.verbose_name}'
        action_url = (
            f'/admin/model/{slug}/{obj.get(meta.pk_field, "")}/edit/'
            if is_edit
            else f'/admin/model/{slug}/add/'
        )
        errors = errors or {}
        
        breadcrumb = f'''
<div class="breadcrumb">
  <a href="/admin">Dashboard</a>
  <a href="/admin/model/{slug}/">{cls._e(meta.verbose_name_plural)}</a>
  <span>{cls._e(title)}</span>
</div>'''
        
        form_fields = meta.get_form_fields()
        readonly_set = set(meta.config.readonly_fields)
        
        field_html_parts = []
        for f in form_fields:
            fname = f['name']
            ftype = f['type']
            label = f['label']
            value = cls._e(obj.get(fname, '') if obj else (f.get('default') or ''))
            required = 'required' if f['required'] else ''
            readonly = 'readonly disabled' if fname in readonly_set else ''
            error = errors.get(fname, '')
            error_html = f'<div style="color:var(--danger);font-size:.8rem;">{cls._e(error)}</div>' if error else ''
            help_text = f'<div class="help">{cls._e(f["comment"])}</div>' if f.get('comment') else ''
            
            # Widget override
            widget = meta.config.form_widgets.get(fname)
            if widget:
                ftype = widget
            
            if ftype == 'textarea':
                inp = f'<textarea class="form-control" name="{fname}" {required} {readonly}>{value}</textarea>'
            elif ftype == 'checkbox':
                checked = 'checked' if value and value.lower() not in ('', '0', 'false', 'none') else ''
                inp = f'''<input type="hidden" name="{fname}" value="0">
<input type="checkbox" name="{fname}" value="1" {checked} {readonly}>'''
            elif ftype == 'select' and hasattr(f.get('column_type_obj'), 'enums'):
                options = ''.join(
                    f'<option value="{cls._e(e)}" {"selected" if e == value else ""}>{cls._e(e)}</option>'
                    for e in f['column_type_obj'].enums
                )
                inp = f'<select class="form-control" name="{fname}" {required} {readonly}>{options}</select>'
            elif ftype in ('number',):
                step = '0.01' if f.get('column_type') in ('Float', 'Numeric', 'DECIMAL') else '1'
                inp = f'<input class="form-control" type="number" name="{fname}" value="{value}" step="{step}" {required} {readonly}>'
            else:
                inp = f'<input class="form-control" type="{ftype}" name="{fname}" value="{value}" {required} {readonly}>'
            
            field_html_parts.append(f'''
<div class="form-group">
  <label for="{fname}">{cls._e(label)} {"*" if f["required"] else ""}</label>
  {inp}
  {help_text}
  {error_html}
</div>''')
        
        fields_html = ''.join(field_html_parts)
        
        delete_link = ''
        if is_edit:
            pk = obj.get(meta.pk_field, '') if obj else ''
            delete_link = f'''
<a class="btn btn-danger" href="/admin/model/{slug}/{pk}/delete/"
   onclick="return confirm('Delete this {cls._e(meta.verbose_name)}?')">
   Delete
</a>'''
        
        form = f'''
{breadcrumb}
<div class="card">
  <h2>{cls._e(title)}</h2>
  <form method="post" action="{action_url}">
    {fields_html}
    <div style="display:flex;gap:1rem;margin-top:1.5rem;">
      <button class="btn btn-primary" type="submit">{"Save Changes" if is_edit else "Create"}</button>
      <a class="btn btn-outline" href="/admin/model/{slug}/">Cancel</a>
      {delete_link}
    </div>
  </form>
</div>'''
        
        return cls.base_layout(title, form)
    
    @classmethod
    def delete_view(cls, meta: 'ModelMeta', obj: dict) -> str:
        """Generate delete confirmation page."""
        slug = meta.name.lower()
        pk = obj.get(meta.pk_field, '')
        
        breadcrumb = f'''
<div class="breadcrumb">
  <a href="/admin">Dashboard</a>
  <a href="/admin/model/{slug}/">{cls._e(meta.verbose_name_plural)}</a>
  <span>Delete</span>
</div>'''
        
        # Show object details
        details = ''.join(
            f'<div><strong>{cls._e(f["label"])}:</strong> {cls._e(obj.get(f["name"], ""))}</div>'
            for f in meta.fields[:5]
        )
        
        content = f'''
{breadcrumb}
<div class="card">
  <h2>⚠️ Delete {cls._e(meta.verbose_name)}</h2>
  <div class="alert alert-danger">
    Are you sure you want to delete this {cls._e(meta.verbose_name)}? This action cannot be undone.
  </div>
  <div style="margin:1rem 0;padding:1rem;background:#f8fafc;border-radius:var(--radius);">
    {details}
  </div>
  <form method="post" action="/admin/model/{slug}/{pk}/delete/">
    <div style="display:flex;gap:1rem;">
      <button class="btn btn-danger" type="submit">Yes, Delete</button>
      <a class="btn btn-outline" href="/admin/model/{slug}/">Cancel</a>
    </div>
  </form>
</div>'''
        
        return cls.base_layout(f'Delete {meta.verbose_name}', content)
    
    @classmethod
    def login_page(cls, error: str = '', site_title: str = 'CREATESONLINE Admin') -> str:
        """Generate login page."""
        error_html = f'<div class="alert alert-danger">{cls._e(error)}</div>' if error else ''
        content = f'''
<div style="max-width:400px;margin:4rem auto;">
  <div class="card">
    <h2 style="text-align:center;">🔐 Admin Login</h2>
    {error_html}
    <form method="post" action="/admin/login">
      <div class="form-group">
        <label>Username</label>
        <input class="form-control" type="text" name="username" required autofocus>
      </div>
      <div class="form-group">
        <label>Password</label>
        <input class="form-control" type="password" name="password" required>
      </div>
      <button class="btn btn-primary" type="submit" style="width:100%;">Sign In</button>
    </form>
  </div>
</div>'''
        return f'''<!DOCTYPE html>
<html lang="en"><head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login | {cls._e(site_title)}</title>
<style>{cls.CSS}</style>
</head><body>{content}</body></html>'''


# ============================================================
# Core Dynamic Admin
# ============================================================

class DynamicAdmin:
    """
    Fully dynamic admin panel — auto-generates CRUD from model introspection.
    
    This is the main class users interact with. Register your SQLAlchemy models
    and get a complete admin interface with zero hardcoded templates.
    """
    
    def __init__(self, session_factory=None, site_title: str = 'CREATESONLINE Admin',
                 auth_fn: Callable = None):
        self.session_factory = session_factory
        self.site_title = site_title
        self.auth_fn = auth_fn    # fn(username, password) -> bool
        self._models: Dict[str, ModelMeta] = {}
        self._html = AdminHTMLGenerator
    
    # ------------------------------------------------------------------
    # Registration
    # ------------------------------------------------------------------
    def register(self, model_class, config: ModelConfig = None):
        """Register a SQLAlchemy model with the admin."""
        name = model_class.__name__
        self._models[name] = ModelMeta(model_class, config)
    
    def unregister(self, model_class):
        name = model_class.__name__
        self._models.pop(name, None)
    
    def auto_discover(self, base_class=None):
        """
        Auto-discover all SQLAlchemy models that inherit from base_class.
        If no base_class is given, tries to auto-detect.
        """
        if base_class is None:
            try:
                from sqlalchemy.orm import DeclarativeBase
                base_class = DeclarativeBase
            except ImportError:
                try:
                    from sqlalchemy.ext.declarative import declarative_base
                    # Can't easily get all subclasses of a dynamically created base
                    return
                except ImportError:
                    return
        
        for subcls in base_class.__subclasses__():
            if hasattr(subcls, '__tablename__') and subcls.__name__ not in self._models:
                self.register(subcls)
            # Recurse
            for sub2 in subcls.__subclasses__():
                if hasattr(sub2, '__tablename__') and sub2.__name__ not in self._models:
                    self.register(sub2)
    
    # ------------------------------------------------------------------
    # Database helpers
    # ------------------------------------------------------------------
    def _get_session(self) -> Optional['Session']:
        if self.session_factory:
            return self.session_factory()
        return None
    
    def _model_to_dict(self, obj, fields: List[dict]) -> dict:
        """Convert a SQLAlchemy model instance to dict."""
        result = {}
        for f in fields:
            val = getattr(obj, f['name'], None)
            if isinstance(val, datetime):
                val = val.isoformat(timespec='minutes')
            elif val is None:
                val = ''
            result[f['name']] = val
        return result
    
    def _get_count(self, session, model_class) -> int:
        try:
            return session.query(func.count()).select_from(model_class).scalar() or 0
        except Exception:
            return 0
    
    def _query_list(self, session, meta: ModelMeta, page: int = 1,
                    search: str = '', order_by: str = None) -> tuple:
        """Query model for list view. Returns (rows, total_count)."""
        q = session.query(meta.model_class)
        
        # Search
        if search and meta.config.search_fields:
            conditions = []
            for sf in meta.config.search_fields:
                col = getattr(meta.model_class, sf, None)
                if col is not None:
                    conditions.append(col.ilike(f'%{search}%'))
            if conditions:
                q = q.filter(or_(*conditions))
        
        total = q.count()
        
        # Ordering
        if order_by:
            col = getattr(meta.model_class, order_by.lstrip('-'), None)
            if col is not None:
                q = q.order_by(col.desc() if order_by.startswith('-') else col)
        elif meta.config.ordering:
            for o in meta.config.ordering:
                col = getattr(meta.model_class, o.lstrip('-'), None)
                if col is not None:
                    q = q.order_by(col.desc() if o.startswith('-') else col)
        elif meta.pk_field:
            pk_col = getattr(meta.model_class, meta.pk_field, None)
            if pk_col is not None:
                q = q.order_by(pk_col.desc())
        
        # Pagination
        offset = (page - 1) * meta.config.list_per_page
        rows = q.offset(offset).limit(meta.config.list_per_page).all()
        
        return [self._model_to_dict(r, meta.fields) for r in rows], total
    
    def _get_object(self, session, meta: ModelMeta, pk) -> Any:
        """Get a single object by primary key."""
        pk_col = getattr(meta.model_class, meta.pk_field)
        return session.query(meta.model_class).filter(pk_col == pk).first()
    
    # ------------------------------------------------------------------
    # Request handling
    # ------------------------------------------------------------------
    def _parse_form_data(self, body: bytes) -> dict:
        """Parse URL-encoded form data."""
        from urllib.parse import parse_qs, unquote_plus
        text = body.decode('utf-8', errors='replace')
        parsed = parse_qs(text, keep_blank_values=True)
        return {k: v[-1] if len(v) == 1 else v for k, v in parsed.items()}
    
    def _respond_html(self, html_content: str, status: int = 200) -> dict:
        """Create an HTML response dict."""
        return {
            'status': status,
            'headers': {'content-type': 'text/html; charset=utf-8'},
            'body': html_content.encode('utf-8') if isinstance(html_content, str) else html_content,
        }
    
    def _respond_redirect(self, url: str, status: int = 302) -> dict:
        return {
            'status': status,
            'headers': {'location': url, 'content-type': 'text/html'},
            'body': b'',
        }
    
    # ------------------------------------------------------------------
    # Route handlers
    # ------------------------------------------------------------------
    async def handle_dashboard(self, request) -> dict:
        """Dashboard view."""
        session = self._get_session()
        counts = {}
        if session:
            try:
                for name, meta in self._models.items():
                    counts[name] = self._get_count(session, meta.model_class)
            finally:
                session.close()
        
        html = self._html.dashboard(self._models, counts, self.site_title)
        return self._respond_html(html)
    
    async def handle_list(self, request, model_name: str) -> dict:
        """List view for a model."""
        meta = self._models.get(model_name)
        if not meta:
            # Try case-insensitive
            for k, v in self._models.items():
                if k.lower() == model_name.lower():
                    meta = v
                    break
        if not meta:
            return self._respond_html(f'<h1>Model not found: {model_name}</h1>', 404)
        
        # Parse query params
        query_params = getattr(request, 'query_params', {})
        page = int(query_params.get('page', 1))
        search = query_params.get('q', '')
        message = query_params.get('msg', '')
        
        session = self._get_session()
        if not session:
            return self._respond_html(self._html.list_view(meta, [], 0, page, search))
        
        try:
            rows, total = self._query_list(session, meta, page, search)
            html = self._html.list_view(meta, rows, total, page, search, message)
            return self._respond_html(html)
        finally:
            session.close()
    
    async def handle_add(self, request, model_name: str) -> dict:
        """Add (create) view."""
        meta = self._resolve_model(model_name)
        if not meta:
            return self._respond_html(f'Model not found: {model_name}', 404)
        
        method = getattr(request, 'method', 'GET').upper()
        
        if method == 'GET':
            html = self._html.form_view(meta)
            return self._respond_html(html)
        
        # POST — create
        body = await request.body() if hasattr(request, 'body') else b''
        data = self._parse_form_data(body)
        
        session = self._get_session()
        if not session:
            return self._respond_html('No database session', 500)
        
        try:
            obj = meta.model_class()
            errors = self._populate_object(obj, data, meta)
            if errors:
                obj_dict = data
                html = self._html.form_view(meta, obj_dict, errors, is_edit=False)
                return self._respond_html(html)
            
            session.add(obj)
            session.commit()
            slug = meta.name.lower()
            return self._respond_redirect(
                f'/admin/model/{slug}/?msg={meta.verbose_name}+created+successfully'
            )
        except Exception as e:
            session.rollback()
            html = self._html.form_view(meta, data, {'_form': str(e)}, is_edit=False)
            return self._respond_html(html)
        finally:
            session.close()
    
    async def handle_edit(self, request, model_name: str, pk: str) -> dict:
        """Edit (update) view."""
        meta = self._resolve_model(model_name)
        if not meta:
            return self._respond_html(f'Model not found: {model_name}', 404)
        
        session = self._get_session()
        if not session:
            return self._respond_html('No database session', 500)
        
        try:
            obj = self._get_object(session, meta, pk)
            if not obj:
                return self._respond_html(f'{meta.verbose_name} not found', 404)
            
            method = getattr(request, 'method', 'GET').upper()
            
            if method == 'GET':
                obj_dict = self._model_to_dict(obj, meta.fields)
                html = self._html.form_view(meta, obj_dict, is_edit=True)
                return self._respond_html(html)
            
            # POST — update
            body = await request.body() if hasattr(request, 'body') else b''
            data = self._parse_form_data(body)
            
            errors = self._populate_object(obj, data, meta)
            if errors:
                obj_dict = {**self._model_to_dict(obj, meta.fields), **data}
                html = self._html.form_view(meta, obj_dict, errors, is_edit=True)
                return self._respond_html(html)
            
            session.commit()
            slug = meta.name.lower()
            return self._respond_redirect(
                f'/admin/model/{slug}/?msg={meta.verbose_name}+updated+successfully'
            )
        except Exception as e:
            session.rollback()
            return self._respond_html(f'Error: {e}', 500)
        finally:
            session.close()
    
    async def handle_delete(self, request, model_name: str, pk: str) -> dict:
        """Delete view."""
        meta = self._resolve_model(model_name)
        if not meta:
            return self._respond_html(f'Model not found: {model_name}', 404)
        
        session = self._get_session()
        if not session:
            return self._respond_html('No database session', 500)
        
        try:
            obj = self._get_object(session, meta, pk)
            if not obj:
                return self._respond_html(f'{meta.verbose_name} not found', 404)
            
            method = getattr(request, 'method', 'GET').upper()
            
            if method == 'GET':
                obj_dict = self._model_to_dict(obj, meta.fields)
                html = self._html.delete_view(meta, obj_dict)
                return self._respond_html(html)
            
            # POST — actually delete
            session.delete(obj)
            session.commit()
            slug = meta.name.lower()
            return self._respond_redirect(
                f'/admin/model/{slug}/?msg={meta.verbose_name}+deleted+successfully'
            )
        except Exception as e:
            session.rollback()
            return self._respond_html(f'Error: {e}', 500)
        finally:
            session.close()
    
    # ------------------------------------------------------------------
    # Helpers
    # ------------------------------------------------------------------
    def _resolve_model(self, name: str) -> Optional[ModelMeta]:
        """Resolve a model name (case-insensitive)."""
        if name in self._models:
            return self._models[name]
        for k, v in self._models.items():
            if k.lower() == name.lower():
                return v
        return None
    
    def _populate_object(self, obj, data: dict, meta: ModelMeta) -> Dict[str, str]:
        """Populate model object from form data. Returns errors dict."""
        errors = {}
        readonly_set = set(meta.config.readonly_fields)
        
        for f in meta.get_form_fields():
            fname = f['name']
            if fname in readonly_set or f['primary_key']:
                continue
            
            if fname not in data:
                if f['required']:
                    errors[fname] = 'This field is required'
                continue
            
            raw = data[fname]
            
            # Type coercion
            try:
                col_type = f.get('column_type', '')
                
                if f['type'] == 'checkbox':
                    val = raw in ('1', 'true', 'True', 'on', True)
                elif col_type in ('Integer', 'BigInteger', 'SmallInteger'):
                    val = int(raw) if raw else None
                elif col_type in ('Float', 'Numeric', 'DECIMAL'):
                    val = float(raw) if raw else None
                elif col_type in ('DateTime', 'TIMESTAMP'):
                    val = datetime.fromisoformat(raw) if raw else None
                elif col_type == 'Date':
                    from datetime import date
                    val = date.fromisoformat(raw) if raw else None
                elif col_type == 'Boolean':
                    val = raw in ('1', 'true', 'True', 'on', True)
                elif col_type in ('JSON', 'JSONB'):
                    val = json.loads(raw) if raw else None
                else:
                    val = raw if raw else (None if f['nullable'] else '')
                
                setattr(obj, fname, val)
            
            except (ValueError, TypeError) as e:
                errors[fname] = f'Invalid value: {e}'
        
        return errors
    
    # ------------------------------------------------------------------
    # Route generation
    # ------------------------------------------------------------------
    def get_routes(self) -> List[dict]:
        """
        Get all admin routes. Mount these in your CREATESONLINE app.
        
        Returns list of dicts with 'path', 'endpoint', 'methods'.
        """
        routes = [
            {'path': '/admin', 'endpoint': self.handle_dashboard, 'methods': ['GET']},
            {'path': '/admin/', 'endpoint': self.handle_dashboard, 'methods': ['GET']},
        ]
        
        # Dynamic model routes
        routes.extend([
            {'path': '/admin/model/{model_name}/', 'endpoint': self.handle_list, 'methods': ['GET']},
            {'path': '/admin/model/{model_name}/add/', 'endpoint': self.handle_add, 'methods': ['GET', 'POST']},
            {'path': '/admin/model/{model_name}/{pk}/edit/', 'endpoint': self.handle_edit, 'methods': ['GET', 'POST']},
            {'path': '/admin/model/{model_name}/{pk}/delete/', 'endpoint': self.handle_delete, 'methods': ['GET', 'POST']},
        ])
        
        return routes
    
    # ------------------------------------------------------------------
    # API endpoints (JSON)
    # ------------------------------------------------------------------
    async def api_list(self, request, model_name: str) -> dict:
        """JSON API: List records."""
        meta = self._resolve_model(model_name)
        if not meta:
            return {'status': 404, 'headers': {'content-type': 'application/json'},
                    'body': json.dumps({'error': 'Model not found'}).encode()}
        
        session = self._get_session()
        if not session:
            return {'status': 500, 'headers': {'content-type': 'application/json'},
                    'body': json.dumps({'error': 'No database'}).encode()}
        
        try:
            query_params = getattr(request, 'query_params', {})
            page = int(query_params.get('page', 1))
            search = query_params.get('q', '')
            rows, total = self._query_list(session, meta, page, search)
            return {
                'status': 200,
                'headers': {'content-type': 'application/json'},
                'body': json.dumps({'data': rows, 'total': total, 'page': page}, default=str).encode(),
            }
        finally:
            session.close()
    
    def get_api_routes(self) -> List[dict]:
        """Get JSON API routes for all models."""
        return [
            {'path': '/api/admin/{model_name}/', 'endpoint': self.api_list, 'methods': ['GET']},
        ]
    
    def __repr__(self):
        return f"DynamicAdmin(models={list(self._models.keys())})"
