from django.shortcuts import render, redirect, get_object_or_404
from django.views.generic import ListView, CreateView, UpdateView, DeleteView, DetailView, TemplateView
from django.views import View  # Add this import
from django.urls import reverse_lazy, reverse  # Add 'reverse' here
from django.db.models import F, Count, Q, Case, When, Value, IntegerField
from django.db.models.functions import TruncDate
from .models import Link, ClickLog, LinkChangeLog, Page, Post, SiteSettings
from .forms import LinkForm, PageForm
from django.core.cache import cache
import json
from django.core.serializers.json import DjangoJSONEncoder
from django.contrib import messages
from django.db import IntegrityError
from django.utils.translation import gettext as _
from django.core.exceptions import ValidationError
from django.http import JsonResponse, HttpResponse
from django.core.serializers import serialize
from django.utils.dateparse import parse_datetime
import random
from django.utils.text import slugify
import markdown
from .templatetags import think_markdown, tasklist_markdown
import logging
import re
import os
import tarfile
import tempfile
from datetime import datetime
from django.conf import settings
from django.http import FileResponse
from wsgiref.util import FileWrapper
from django.contrib.auth.decorators import user_passes_test
from django.contrib.auth.mixins import LoginRequiredMixin
import requests
from django.views.decorators.http import require_http_methods, require_POST
from django.views.decorators.csrf import csrf_exempt
from bs4 import BeautifulSoup

from .page_views import (
    PageListView, PageDetailView, PageCreateView,
    PageUpdateView, PageDeleteView, fetch_page_info,
    PageViewSet
)
from .search_views import search_aliases, search

logger = logging.getLogger(__name__)

class LinkListView(ListView):
    model = Link
    template_name = 'links/link_list.html'
    context_object_name = 'links'

    def get_queryset(self):
        queryset = super().get_queryset()
        sort_by = self.request.GET.get('sort', 'click_count')
        order = self.request.GET.get('order', 'asc')

        if sort_by == 'alias':
            ordering = F('alias').asc(nulls_last=True) if order == 'asc' else F('alias').desc(nulls_last=True)
        elif sort_by == 'link_type':
            ordering = F('link_type').asc(nulls_last=True) if order == 'asc' else F('link_type').desc(nulls_last=True)
        elif sort_by == 'original_url':
            ordering = F('original_url').asc(nulls_last=True) if order == 'asc' else F('original_url').desc(nulls_last=True)
        elif sort_by == 'clicks':
            ordering = F('click_count').asc(nulls_last=True) if order == 'asc' else F('click_count').desc(nulls_last=True)
        elif sort_by == 'created_at':
            ordering = F('created_at').asc(nulls_last=True) if order == 'asc' else F('created_at').desc(nulls_last=True)
        elif sort_by == 'updated_at':
            ordering = F('updated_at').asc(nulls_last=True) if order == 'asc' else F('updated_at').desc(nulls_last=True)
        else:
            ordering = F('click_count').desc(nulls_last=True)

        return queryset.order_by(ordering)

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context['current_sort'] = self.request.GET.get('sort', 'click_count')
        context['current_order'] = self.request.GET.get('order', 'asc')

        # Modern color palette
        colors = [
            '#3498db', '#2ecc71', '#e74c3c', '#f39c12', '#9b59b6',
            '#1abc9c', '#d35400', '#34495e', '#16a085', '#27ae60'
        ]

        # Add latest 3 posts to the context (most recent first)
        context['latest_posts'] = Post.objects.all().order_by('-created_at')[:3]

        # Add pinned mini apps with random colors
        mini_apps = [
            {'name': 'Image Gallery', 'description': 'Browse Images', 'url': 'mini-apps-image-gallery'},
            {'name': 'TTS', 'description': 'Convert text to speech', 'url': 'mini-apps-tts'},
        ]
        context['mini_apps'] = [
            {**app, 'color': random.choice(colors)}
            for app in mini_apps
        ]

        # Add timestamp for random image cache busting
        import time
        context['timestamp'] = int(time.time())

        return context

class LinkCreateView(CreateView):
    model = Link
    form_class = LinkForm
    template_name = 'links/link_form.html'  # 确保这里的路径是正确的
    success_url = reverse_lazy('link_list')

    def get_initial(self):
        initial = super().get_initial()
        alias = self.request.GET.get('alias')
        if alias:
            initial['alias'] = alias
        return initial

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context['is_new'] = True
        return context

    def form_valid(self, form):
        form.instance.alias = form.instance.alias.lower()
        alias = form.cleaned_data.get('alias').lower()
        if not self.is_valid_alias(alias):
            form.add_error('alias', _("Alias cannot contain special characters."))
            return self.form_invalid(form)
        if Link.objects.filter(alias=alias).exists():
            form.add_error('alias', _("This alias is already in use. Please choose another one."))
            return self.form_invalid(form)

        # Set original_url for custom links
        if form.cleaned_data.get('link_type') == Link.LinkType.CUSTOM:
            form.instance.original_url = f'/custom/{alias}'

        return super().form_valid(form)

    def is_valid_alias(self, alias):
        return bool(re.match(r'^[a-zA-Z0-9_-]+$', alias))

class LinkUpdateView(UpdateView):
    model = Link
    form_class = LinkForm
    template_name = 'links/link_form.html'

    def dispatch(self, request, *args, **kwargs):
        response = super().dispatch(request, *args, **kwargs)
        # Prevent caching to ensure fresh markdown content after task toggles
        response['Cache-Control'] = 'no-cache, no-store, must-revalidate'
        response['Pragma'] = 'no-cache'
        response['Expires'] = '0'
        return response

    def get_object(self, queryset=None):
        # Handle both pk (for regular links) and alias (for custom links)
        pk = self.kwargs.get('pk')
        alias = self.kwargs.get('alias')

        if pk:
            # Regular link lookup by pk
            return get_object_or_404(Link, pk=pk)
        elif alias:
            # Custom link lookup by alias
            alias = alias.lower()
            return get_object_or_404(Link, alias=alias, link_type=Link.LinkType.CUSTOM)
        else:
            # Fallback to default behavior
            return super().get_object(queryset)

    def get_initial(self):
        initial = super().get_initial()
        self.original_url = self.object.original_url
        return initial

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        link = self.get_object()
        context['is_custom'] = link.link_type == Link.LinkType.CUSTOM
        return context

    def form_valid(self, form):
        form.instance.alias = form.instance.alias.lower()
        alias = form.cleaned_data.get('alias').lower()
        if not self.is_valid_alias(alias):
            form.add_error('alias', _("Alias cannot contain special characters."))
            return self.form_invalid(form)
        if Link.objects.filter(alias=alias).exclude(pk=self.object.pk).exists():
            form.add_error('alias', _("This alias is already in use. Please choose another one."))
            return self.form_invalid(form)

        # 更新 custome link type, original_url 字段
        if form.cleaned_data.get('link_type') == Link.LinkType.CUSTOM:
            form.instance.original_url = f'/custom/{alias}'

        response = super().form_valid(form)

        # Invalidate the alias cache so the redirect hot-path sees the updated link
        cache.delete(f"link:alias:{form.instance.alias.lower()}")

        new_url = form.cleaned_data['original_url']
        if self.original_url != new_url:
            logger.info(f"URL changed from {self.original_url} to {new_url}")
            try:
                self.object.log_url_change(self.original_url)
                logger.info("LinkChangeLog created successfully")
            except Exception as e:
                logger.error(f"Error creating LinkChangeLog: {str(e)}")
        else:
            logger.info("URL did not change")

        return response

    def form_invalid(self, form):
        for field, errors in form.errors.items():
            for error in errors:
                messages.error(self.request, f"{error}")
        return super().form_invalid(form)

    def get_success_url(self):
        return reverse('link_detail', kwargs={'pk': self.object.pk})

    def is_valid_alias(self, alias):
        return bool(re.match(r'^[a-zA-Z0-9_-]+$', alias))

class LinkDeleteView(DeleteView):
    model = Link
    template_name = 'links/link_confirm_delete.html'
    success_url = reverse_lazy('link_list')

    def delete(self, request, *args, **kwargs):
        cache.delete(f"link:alias:{self.get_object().alias.lower()}")
        return super().delete(request, *args, **kwargs)

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context['object'] = self.get_object()
        return context

def delete_selected(request):
    if request.method == 'POST':
        selected_ids = request.POST.getlist('selected_links')
        Link.objects.filter(id__in=selected_ids).delete()
    return redirect('link_list')

def redirect_to_original(request, alias, param=None):
    processed_alias = ''.join(e for e in alias.lower() if e.isalnum())

    try:
        cache_key = f'link:alias:{processed_alias}'
        link = cache.get(cache_key)
        if link is None:
            link = Link.objects.get(alias=processed_alias)
            cache.set(cache_key, link, timeout=3600)

        # Buffer click in Redis (atomic increment). A periodic task flushes to DB.
        from django.utils import timezone
        date_str = timezone.now().strftime('%Y-%m-%d')
        click_key = f'clicks:{link.id}:{date_str}'
        try:
            cache.incr(click_key)
        except ValueError:
            # Key doesn't exist yet — set it then give it a 48-hour safety-net TTL
            cache.set(click_key, 1, timeout=172800)

        try:
            # 如果是模板 URL 并且提供了参数
            if param:
                # 从 URL 中提取参数名
                pattern = r'\{([^{}]*)\}'
                matches = re.finditer(pattern, link.original_url)
                for match in matches:
                    param_str = match.group(1)
                    if ',' in param_str:
                        param_name = param_str.split(',')[0].strip()
                    else:
                        param_name = param_str.strip()
                    # 使用提供的参数值
                    url = link.get_processed_url(**{param_name: param})
                    return redirect(url)

            # 如果没有提供参数，使用默认值
            url = link.get_processed_url()
            if url is None:
                url = link.original_url
            return redirect(url)
        except ValueError as e:
            messages.error(request, str(e))
            return redirect('link_list')

    except Link.DoesNotExist:
        # Also clear any stale cache entry that might be causing the miss
        cache.delete(f'link:alias:{processed_alias}')
        messages.warning(request, _("The alias '{}' doesn't exist. Do you want to create a new one?").format(processed_alias))
        return redirect(reverse('link_create') + f'?alias={processed_alias}')

class LinkDetailView(DetailView):
    model = Link
    template_name = 'links/link_detail.html'
    context_object_name = 'link'

    def get_object(self, queryset=None):
        if 'pk' in self.kwargs:
            return super().get_object(queryset)
        elif 'alias' in self.kwargs:
            return get_object_or_404(Link, alias=self.kwargs['alias'])

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        from datetime import datetime, timedelta
        import calendar

        # Get the time period from request (default to 3 months)
        period = self.request.GET.get('period', '3m')

        # Calculate date range and interval based on period
        end_date = datetime.now().date()
        if period == '3m':
            start_date = end_date - timedelta(days=89)  # 90 days total
            period_name = "3 Months"
            interval_days = 1  # Daily
        elif period == '6m':
            start_date = end_date - timedelta(days=179)  # 180 days total
            period_name = "6 Months"
            interval_days = 7  # Weekly
        elif period == '1y':
            start_date = end_date - timedelta(days=364)  # 365 days total
            period_name = "1 Year"
            interval_days = 7  # Weekly
        elif period == 'all':
            # Get the earliest click date, or default to 1 year ago if no clicks
            earliest_click = ClickLog.objects.filter(link=self.object).order_by('clicked_at').first()
            if earliest_click:
                start_date = earliest_click.clicked_at.date()
                # Determine interval based on data range
                days_range = (end_date - start_date).days
                if days_range <= 90:
                    interval_days = 7  # Weekly
                elif days_range <= 365:
                    interval_days = 7  # Weekly
                else:
                    interval_days = 30  # Monthly
            else:
                start_date = end_date - timedelta(days=364)
                interval_days = 7  # Weekly
            period_name = "All Time"
        else:
            start_date = end_date - timedelta(days=89)  # Default to 3 months
            period_name = "3 Months"
            interval_days = 1  # Daily

        # Get click stats — cache per link+period to avoid repeated ClickLog full-scans
        stats_cache_key = f'link:stats:{self.object.pk}:{period}'
        click_stats_list = cache.get(stats_cache_key)

        if click_stats_list is None:
            # Get click stats from database
            click_stats = ClickLog.objects.filter(
                link=self.object,
                clicked_at__date__gte=start_date,
                clicked_at__date__lte=end_date
            ).annotate(
                date=TruncDate('clicked_at')
            ).values('date').annotate(count=Count('id')).order_by('date')

            # Convert to dictionary for easy lookup
            click_dict = {item['date']: item['count'] for item in click_stats}

            # Create complete dataset with appropriate intervals
            click_stats_list = []
            current_date = start_date

            if interval_days == 1:
                # Daily intervals
                while current_date <= end_date:
                    click_stats_list.append({
                        'date': current_date.strftime('%Y-%m-%d'),
                        'count': click_dict.get(current_date, 0)
                    })
                    current_date += timedelta(days=1)
            else:
                # Weekly, bi-weekly, or monthly intervals
                while current_date <= end_date:
                    interval_end = min(current_date + timedelta(days=interval_days - 1), end_date)

                    # Sum clicks for this interval
                    interval_count = 0
                    temp_date = current_date
                    while temp_date <= interval_end:
                        interval_count += click_dict.get(temp_date, 0)
                        temp_date += timedelta(days=1)

                    # Format label based on interval
                    if interval_days == 7:  # Weekly
                        label = f"{current_date.strftime('%m/%d')}"
                    elif interval_days == 14:  # Bi-weekly
                        label = f"{current_date.strftime('%m/%d')}"
                    else:  # Monthly
                        label = f"{calendar.month_abbr[current_date.month]} {current_date.year}"

                    click_stats_list.append({
                        'date': current_date.strftime('%Y-%m-%d'),
                        'count': interval_count,
                        'label': label
                    })
                    current_date += timedelta(days=interval_days)

            cache.set(stats_cache_key, click_stats_list, timeout=600)

        context['click_stats'] = json.dumps(click_stats_list, cls=DjangoJSONEncoder)
        context['current_period'] = period
        context['period_name'] = period_name
        context['interval_days'] = interval_days

        # Convert markdown to HTML if the link is a custom type
        if self.object.link_type == Link.LinkType.CUSTOM:
            task_states = self.object.task_states if self.object.task_states else {}
            context['rendered_text'] = markdown.markdown(
                self.object.text,
                extensions=[
                    'markdown.extensions.fenced_code',
                    'markdown.extensions.tables',
                    'markdown.extensions.nl2br',
                    think_markdown.ThinkExtension(),
                    tasklist_markdown.TaskListExtension(task_states=task_states)
                ]
            )

        # 获取所有与此链接相关的变更日志
        context['change_logs'] = LinkChangeLog.objects.filter(link=self.object).order_by('-changed_at')

        return context

class ToolsView(View):
    template_name = 'links/tools.html'

    def get(self, request):
        return render(request, self.template_name)

    def post(self, request):
        if 'export' in request.POST:
            links = Link.objects.prefetch_related('tags').all()

            formatted_data = []
            for link in links:
                # Get all tag names for this link
                tag_names = [tag.name for tag in link.tags.all()]

                # Build the basic link data
                link_data = {
                    'alias': link.alias,
                    'original_url': link.original_url,
                    'link_type': link.link_type,
                    'created_at': link.created_at.isoformat() if link.created_at else None,
                    'updated_at': link.updated_at.isoformat() if link.updated_at else None,
                    'tags': tag_names
                }

                # For custom types, include the text field (markdown content)
                if link.link_type == Link.LinkType.CUSTOM and link.text:
                    link_data['text'] = link.text

                formatted_data.append(link_data)

            response = HttpResponse(json.dumps(formatted_data, indent=2), content_type='application/json')
            response['Content-Disposition'] = 'attachment; filename="links_export.json"'
            return response
        elif 'import' in request.POST:
            try:
                json_file = request.FILES['json_file']
                data = json.load(json_file)

                if not isinstance(data, list):
                    raise ValueError(_("Invalid JSON format. Expected a list of objects."))

                imported_count = 0
                skipped_count = 0
                for item in data:
                    if not isinstance(item, dict) or 'alias' not in item:
                        raise ValueError(_("Invalid item in JSON. Each item must be an object with an 'alias' field."))

                    if not Link.objects.filter(alias=item['alias']).exists():
                        Link.objects.create(
                            alias=item['alias'],
                            original_url=item.get('original_url', ''),
                            created_at=parse_datetime(item.get('created_at')) if item.get('created_at') else None,
                            updated_at=parse_datetime(item.get('updated_at')) if item.get('updated_at') else None
                        )
                        imported_count += 1
                    else:
                        skipped_count += 1

                messages.success(request, _(f"{imported_count} aliases have been imported successfully. {skipped_count} were skipped."))
            except json.JSONDecodeError:
                messages.error(request, _("Invalid JSON file. Please check the file format."))
            except ValueError as e:
                messages.error(request, str(e))
            except Exception as e:
                messages.error(request, _(f"An error occurred during import: {str(e)}"))

        return render(request, self.template_name)

class CustomLinkView(DetailView):
    model = Link
    template_name = 'links/custom_link.html'
    context_object_name = 'link'

    def get_object(self, queryset=None):
        # Convert all uppercases to lowercases
        alias = self.kwargs.get('alias').lower()
        return get_object_or_404(Link, alias=alias, link_type=Link.LinkType.CUSTOM)

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        # Render markdown text as HTML with task list support
        task_states = self.object.task_states if self.object.task_states else {}
        context['rendered_text'] = markdown.markdown(
            self.object.text,
            extensions=[
                'markdown.extensions.fenced_code',
                'markdown.extensions.tables',
                'markdown.extensions.nl2br',
                think_markdown.ThinkExtension(),
                tasklist_markdown.TaskListExtension(task_states=task_states)
            ]
        )
        return context

def export_database(request):
    """Export the entire SQLite database as a tarball"""
    try:
        # 获取数据库文件路径
        db_path = settings.DATABASES['default']['NAME']

        # 创建临时目录
        with tempfile.TemporaryDirectory() as temp_dir:
            # 创建带时间戳的文件名
            timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
            tar_filename = f'database_backup_{timestamp}.tar.gz'
            tar_filepath = os.path.join(temp_dir, tar_filename)

            # 创建tar文件
            with tarfile.open(tar_filepath, 'w:gz') as tar:
                # 添加数据库文件到tar
                tar.add(db_path, arcname=os.path.basename(db_path))

            # 打开文件准备下载
            wrapper = FileWrapper(open(tar_filepath, 'rb'))
            response = FileResponse(wrapper, content_type='application/x-gzip')
            response['Content-Disposition'] = f'attachment; filename="{tar_filename}"'
            response['Content-Length'] = os.path.getsize(tar_filepath)

            return response

    except Exception as e:
        logger.error(f"Database export failed: {str(e)}")
        messages.error(request, _("Failed to export database: {}").format(str(e)))
        return redirect('tools')

class HelpView(TemplateView):
    template_name = 'links/help.html'


@require_POST
@csrf_exempt  # CSRF is checked manually via header
def test_telegram_view(request):
    """Dedicated endpoint for testing Telegram credentials without saving them."""
    import json
    import requests as req

    # Accept both JSON body and form-encoded body
    if request.content_type and 'application/json' in request.content_type:
        try:
            body = json.loads(request.body)
        except (ValueError, KeyError):
            body = {}
        token = body.get('token', '').strip()
        chat_id = body.get('chat_id', '').strip()
    else:
        token = request.POST.get('token', '').strip()
        chat_id = request.POST.get('chat_id', '').strip()

    if not token or not chat_id:
        return JsonResponse({'ok': False, 'error': _('Bot Token and Chat ID are required.')})

    try:
        resp = req.post(
            f'https://api.telegram.org/bot{token}/sendMessage',
            json={'chat_id': chat_id, 'text': '✅ Test notification from Links app — Telegram is working!'},
            timeout=10,
        )
        resp.raise_for_status()
        return JsonResponse({'ok': True})
    except req.exceptions.HTTPError as exc:
        try:
            detail = exc.response.json().get('description', str(exc))
        except Exception:
            detail = str(exc)
        return JsonResponse({'ok': False, 'error': detail})
    except Exception as exc:
        return JsonResponse({'ok': False, 'error': str(exc)})


class SiteSettingsView(View):
    template_name = 'links/settings.html'

    def get(self, request):
        site_settings = SiteSettings.get()
        public_posts = Post.objects.filter(is_public=True).order_by('-updated_at')
        return render(request, self.template_name, {
            'site_settings': site_settings,
            'public_posts': public_posts,
        })

    def post(self, request):
        from apscheduler.triggers.interval import IntervalTrigger
        from core.scheduler import scheduler
        from pricemon.notifications import send_test_telegram

        site_settings = SiteSettings.get()

        if 'test_telegram' in request.POST:
            token = request.POST.get('telegram_bot_token', '').strip()
            chat_id = request.POST.get('telegram_chat_id', '').strip()
            result = send_test_telegram(token, chat_id)
            if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
                status_code = 200 if result['ok'] else 400
                return JsonResponse(result, status=status_code)
            if result['ok']:
                messages.success(request, _('Test Telegram message sent.'))
            else:
                messages.error(request, _('Telegram test failed: {}').format(result['error']))
            return redirect('site-settings')

        site_settings.telegram_bot_token = request.POST.get('telegram_bot_token', '').strip()
        site_settings.telegram_chat_id = request.POST.get('telegram_chat_id', '').strip()
        site_settings.public_sharing_domain = request.POST.get('public_sharing_domain', '').strip()
        try:
            max_jobs = int(request.POST.get('max_concurrent_screenshot_jobs', 2))
            site_settings.max_concurrent_screenshot_jobs = max(1, min(max_jobs, 20))
        except (ValueError, TypeError):
            pass
        try:
            pages_interval = int(request.POST.get('schedule_pending_pages_interval', 120))
            site_settings.schedule_pending_pages_interval = max(10, min(pages_interval, 3600))
        except (ValueError, TypeError):
            pass
        try:
            screenshots_interval = int(request.POST.get('schedule_pending_screenshots_interval', 120))
            site_settings.schedule_pending_screenshots_interval = max(10, min(screenshots_interval, 3600))
        except (ValueError, TypeError):
            pass
        site_settings.save()

        # Reschedule periodic jobs with the new intervals
        try:
            scheduler.reschedule_job(
                'schedule_pending_pages',
                trigger=IntervalTrigger(seconds=site_settings.schedule_pending_pages_interval),
            )
            scheduler.reschedule_job(
                'schedule_pending_screenshots',
                trigger=IntervalTrigger(seconds=site_settings.schedule_pending_screenshots_interval),
            )
        except Exception:
            pass  # Scheduler may not be running in test/CLI context

        messages.success(request, _('Settings saved.'))
        return redirect('site-settings')


class JobsView(View):
    template_name = 'links/jobs.html'
    PAGE_SIZE = 50

    def get(self, request):
        from . import job_registry
        from .models import SiteSettings as SS
        from core.scheduler import scheduler
        from django.core.paginator import Paginator

        job_types = job_registry.all_types()
        tab = request.GET.get('tab', job_types[0]['id'] if job_types else 'scheduler')
        status_filter = request.GET.get('status', 'all')
        page_num = request.GET.get('page', 1)

        # Build stats for all registered job types
        job_type_stats = []
        for jt in job_types:
            raw = jt['get_stats']()
            job_type_stats.append({
                'id': jt['id'],
                'label': jt['label'],
                'icon_color': jt['icon_color'],
                'icon_path': jt['icon_path'],
                'total': raw.get('total', 0),
                'pending': raw.get('pending'),
                'processing': raw.get('processing'),
                'completed': raw.get('completed'),
                'failed': raw.get('failed'),
            })

        # Tab navigation list
        job_type_tabs = [
            {
                'id': jt['id'],
                'label': jt['label'],
                'count': next((s['total'] for s in job_type_stats if s['id'] == jt['id']), 0),
                'is_active': tab == jt['id'],
            }
            for jt in job_types
        ]

        # APScheduler jobs
        scheduled_jobs = []
        if scheduler.running:
            for job in scheduler.get_jobs():
                scheduled_jobs.append({
                    'id': job.id,
                    'name': job.func.__name__ if callable(job.func) else str(job.func),
                    'next_run': str(job.next_run_time) if job.next_run_time else None,
                    'trigger': str(job.trigger),
                })

        # Handle scheduler tab or unrecognised tab
        current_jt = job_registry.get_type(tab)
        if tab == 'scheduler' or current_jt is None:
            return render(request, self.template_name, {
                'job_type_tabs': job_type_tabs,
                'job_type_stats': job_type_stats,
                'tab': 'scheduler',
                'status_filter': status_filter,
                'page_obj': None,
                'page_rows': [],
                'all_ids': [],
                'bulk_actions': {},
                'status_choices': [],
                'current_tab_config': None,
                'scheduled_jobs': scheduled_jobs,
                'scheduler_running': scheduler.running,
                'site_settings': SS.get(),
            })

        # Paginate and serialize rows for the active job type
        qs = current_jt['get_queryset'](status_filter)
        paginator = Paginator(qs, self.PAGE_SIZE)
        page_obj = paginator.get_page(page_num)
        page_rows = [current_jt['serialize'](obj) for obj in page_obj.object_list]

        return render(request, self.template_name, {
            'job_type_tabs': job_type_tabs,
            'job_type_stats': job_type_stats,
            'tab': tab,
            'status_filter': status_filter,
            'page_obj': page_obj,
            'page_rows': page_rows,
            'all_ids': [r['id'] for r in page_rows],
            'bulk_actions': current_jt.get('bulk_actions', {}),
            'status_choices': current_jt.get('status_choices', []),
            'current_tab_config': {
                'id': current_jt['id'],
                'label': current_jt['label'],
                'title_label': current_jt.get('title_label', 'Title'),
                'columns': current_jt.get('columns', ['id', 'title', 'status', 'updated']),
            },
            'scheduled_jobs': scheduled_jobs,
            'scheduler_running': scheduler.running,
            'site_settings': SS.get(),
        })

    def post(self, request):
        action = request.POST.get('action')
        ids_raw = request.POST.get('ids', '')
        from .models import Screenshot, Page

        # Parse selected IDs (comma-separated or multi-value)
        ids = [i.strip() for i in ids_raw.split(',') if i.strip()]

        # ── Bulk screenshot actions ──────────────────────────────────────
        if action == 'bulk_fail_screenshots':
            qs = Screenshot.objects.filter(pk__in=ids) if ids else Screenshot.objects.none()
            n = qs.update(status=Screenshot.Status.FAILED, error='Marked failed by user')
            messages.success(request, _(f'Marked {n} screenshot(s) as failed.'))

        elif action == 'bulk_retry_screenshots':
            qs = Screenshot.objects.filter(pk__in=ids) if ids else Screenshot.objects.none()
            n = qs.update(status=Screenshot.Status.PENDING, retry_count=0, error=None)
            messages.success(request, _(f'Reset {n} screenshot(s) to pending.'))

        elif action == 'bulk_delete_screenshots':
            qs = Screenshot.objects.filter(pk__in=ids) if ids else Screenshot.objects.none()
            n = qs.delete()[0]
            messages.success(request, _(f'Deleted {n} screenshot record(s).'))

        # ── Bulk page actions ─────────────────────────────────────────────
        elif action == 'bulk_fail_pages':
            qs = Page.objects.filter(pk__in=ids) if ids else Page.objects.none()
            n = qs.update(process_status=Page.ProcessStatus.FAILED, error_message='Marked failed by user')
            messages.success(request, _(f'Marked {n} page(s) as failed.'))

        elif action == 'bulk_retry_pages':
            qs = Page.objects.filter(pk__in=ids) if ids else Page.objects.none()
            n = qs.update(process_status=Page.ProcessStatus.PENDING, retry_count=0, error_message='')
            messages.success(request, _(f'Reset {n} page(s) to pending.'))

        elif action == 'bulk_delete_pages':
            qs = Page.objects.filter(pk__in=ids) if ids else Page.objects.none()
            n = qs.delete()[0]
            messages.success(request, _(f'Deleted {n} page(s).'))

        # ── Bulk image import actions ─────────────────────────────────────
        elif action == 'bulk_retry_image_imports':
            from .models import FileUpload
            from .tasks import download_and_save_image
            from threading import Thread as _Thread
            qs = FileUpload.objects.filter(pk__in=ids) if ids else FileUpload.objects.none()
            n = 0
            for record in qs:
                _Thread(target=download_and_save_image, args=(str(record.pk),), daemon=True).start()
                n += 1
            messages.success(request, _(f'Retrying {n} image import(s).'))

        elif action == 'bulk_delete_image_imports':
            from .models import FileUpload
            import os as _os
            qs = FileUpload.objects.filter(pk__in=ids) if ids else FileUpload.objects.none()
            n = 0
            for record in qs:
                if _os.path.exists(record.file_path):
                    try:
                        _os.remove(record.file_path)
                    except OSError:
                        pass
                record.delete()
                n += 1
            messages.success(request, _(f'Deleted {n} image import record(s).'))

        elif action == 'bulk_delete_netscan_runs':
            from netscan.models import ScanRun
            qs = ScanRun.objects.filter(pk__in=ids) if ids else ScanRun.objects.none()
            n = qs.delete()[0]
            messages.success(request, _(f'Deleted {n} netscan run(s).'))

        elif action == 'bulk_delete_knowledge_graph_snapshots':
            messages.error(request, _('Unknown action.'))

        # Preserve tab/status after POST
        tab = request.POST.get('tab', 'screenshots')
        status_filter = request.POST.get('status_filter', 'all')
        return redirect(f"{request.path}?tab={tab}&status={status_filter}")

@require_http_methods(["POST"])
def generate_tts(request, post_id):
    """Generate TTS audio for a post"""
    try:
        post = get_object_or_404(Post, pk=post_id)

        # Get voice parameter from request body
        voice = 'zh-CN-XiaomengNeural'  # Default voice
        if request.content_type == 'application/json' and request.body:
            import json
            data = json.loads(request.body)
            voice = data.get('voice', 'zh-CN-XiaomengNeural')

        # Clean the post content - remove markdown and HTML
        clean_text = clean_text_for_tts(post.content)

        if not clean_text.strip():
            return JsonResponse({'error': 'No text content to convert'}, status=400)

        # Call TTS API with voice parameter
        import urllib.parse
        encoded_text = urllib.parse.quote(clean_text)
        encoded_voice = urllib.parse.quote(voice)
        tts_url = f"https://home-tts.junv.workers.dev/tts?text={encoded_text}&voice={encoded_voice}&haha=hahaJunv"

        try:
            response = requests.get(tts_url, timeout=30)
            response.raise_for_status()

            # Return the audio file
            audio_response = HttpResponse(response.content, content_type='audio/mpeg')
            audio_response['Content-Disposition'] = f'inline; filename="post_{post_id}_tts.mp3"'
            return audio_response

        except requests.RequestException as e:
            return JsonResponse({'error': f'TTS service error: {str(e)}'}, status=500)

    except Post.DoesNotExist:
        return JsonResponse({'error': 'Post not found'}, status=404)
    except Exception as e:
        return JsonResponse({'error': f'Server error: {str(e)}'}, status=500)

@require_http_methods(["POST"])
def generate_tts_api(request):
    """General TTS API that accepts text content and voice parameter"""
    try:
        import json

        # Get text and voice from request body
        if request.content_type == 'application/json':
            data = json.loads(request.body)
            text_content = data.get('text', '')
            voice = data.get('voice', 'zh-CN-XiaomengNeural')  # Default to first Chinese voice
        else:
            text_content = request.POST.get('text', '')
            voice = request.POST.get('voice', 'zh-CN-XiaomengNeural')

        if not text_content.strip():
            return JsonResponse({'error': 'No text content provided'}, status=400)

        # Clean the text content - remove markdown and HTML
        clean_text = clean_text_for_tts(text_content)

        if not clean_text.strip():
            return JsonResponse({'error': 'No valid text content to convert'}, status=400)

        # Call TTS API with voice parameter
        import urllib.parse
        encoded_text = urllib.parse.quote(clean_text)
        encoded_voice = urllib.parse.quote(voice)
        tts_url = f"https://home-tts.junv.workers.dev/tts?text={encoded_text}&voice={encoded_voice}&haha=hahaJunv"

        try:
            response = requests.get(tts_url, timeout=30)
            response.raise_for_status()

            # Return the audio file
            audio_response = HttpResponse(response.content, content_type='audio/mpeg')
            audio_response['Content-Disposition'] = f'inline; filename="tts_audio.mp3"'
            return audio_response

        except requests.RequestException as e:
            return JsonResponse({'error': f'TTS service error: {str(e)}'}, status=500)

    except json.JSONDecodeError:
        return JsonResponse({'error': 'Invalid JSON data'}, status=400)
    except Exception as e:
        return JsonResponse({'error': f'Server error: {str(e)}'}, status=500)

def clean_text_for_tts(content):
    """Clean markdown and HTML from text for TTS"""
    # Convert markdown to HTML first
    html = markdown.markdown(content)

    # Parse HTML and extract text
    soup = BeautifulSoup(html, 'html.parser')

    # Remove script and style elements
    for script in soup(["script", "style"]):
        script.decompose()

    # Get text and clean it
    text = soup.get_text()

    # Clean up whitespace and special characters
    text = re.sub(r'\s+', ' ', text)  # Multiple spaces to single
    text = re.sub(r'\n+', '. ', text)  # Multiple newlines to period
    text = text.strip()

    # Limit text length (TTS services often have limits)
    if len(text) > 3000:
        text = text[:3000] + "..."

    return text


@require_http_methods(["POST"])
@csrf_exempt
def toggle_link_task(request, pk):
    """
    Toggle the checked state of a task in a link's custom content.
    Only updates task_states JSON, keeps markdown unchanged.

    POST /link/{id}/toggle_task/
    Body: {"task_hash": "abc123"}
    """
    try:
        link = get_object_or_404(Link, pk=pk)

        # Parse JSON body
        import json
        data = json.loads(request.body)
        task_hash = data.get('task_hash')

        if not task_hash:
            return JsonResponse(
                {'error': 'task_hash is required'},
                status=400
            )

        # Get current task states or initialize empty dict
        task_states = link.task_states if link.task_states else {}

        # Toggle the state (default to False if not set, then toggle)
        current_state = task_states.get(task_hash, False)
        new_state = not current_state
        task_states[task_hash] = new_state

        link.task_states = task_states

        # Save only task_states, leave markdown text unchanged
        link.save(update_fields=['task_states', 'updated_at'])

        # Extract task text from markdown for logging
        task_text = _extract_task_text_from_hash(link.text, task_hash)

        # Log the task toggle with metadata
        change_log = LinkChangeLog.objects.create(
            link=link,
            change_type=LinkChangeLog.ChangeType.TASK_TOGGLE,
            metadata={
                'task_hash': task_hash,
                'task_text': task_text,
                'old_state': current_state,
                'new_state': new_state
            }
        )

        return JsonResponse({
            'task_hash': task_hash,
            'checked': new_state
        })
    except json.JSONDecodeError:
        return JsonResponse({'error': 'Invalid JSON'}, status=400)
    except Exception as e:
        logger.error(f"Error in toggle_link_task: {e}", exc_info=True)
        return JsonResponse({'error': str(e)}, status=500)


def _extract_task_text_from_hash(markdown_text, target_hash):
    """
    Extract the task text that corresponds to a given hash from markdown content.

    Args:
        markdown_text: The markdown content containing task lists
        target_hash: The hash of the task to find

    Returns:
        The task text if found, otherwise the hash itself
    """
    if not markdown_text:
        return target_hash

    import hashlib

    # Pattern to match task list items: - [ ] or - [x] followed by text
    task_pattern = re.compile(r'^[\s]*[-*]\s+\[([ xX])\]\s+(.+)$', re.MULTILINE)

    for match in task_pattern.finditer(markdown_text):
        task_text = match.group(2).strip()

        # Compute hash the same way as in tasklist_markdown.py
        normalized = ' '.join(task_text.split())
        task_hash = hashlib.md5(normalized.encode('utf-8')).hexdigest()[:12]

        if task_hash == target_hash:
            return task_text

    # If not found, return the hash
    return target_hash
