import ast
import difflib
import json
import logging
import re
import requests
import time

from markupsafe import Markup

from odoo import api, fields, models, _
from odoo.exceptions import UserError, ValidationError
from odoo.tools import html2plaintext

_logger = logging.getLogger(__name__)

# === Input Validation Constants ===
MAX_KEYWORDS_LENGTH = 500
MAX_TOPIC_LENGTH = 5000
MAX_SOURCE_TEXT_LENGTH = 50000
MAX_CUSTOM_INSTRUCTIONS_LENGTH = 2000
MAX_REFERENCES = 5


class OtkSeoContent(models.Model):
    _name = 'otk.seo.content'
    _description = 'SEO Content'
    _inherit = ['mail.thread', 'website.seo.metadata']
    _order = 'create_date desc'

    # === Identification ===
    name = fields.Char('Title', required=True, tracking=True,
        help='The main title for your content. This will be used as the default title if no AI-generated title is created.')

    content_type = fields.Selection([
        ('blog_post', 'Blog Post'),
        ('product_desc', 'Product Description'),
        ('category_desc', 'Category Description'),
        ('landing_page', 'Landing Page Content'),
        ('social_post', 'Social Media Post'),
    ], string='Content Type', required=True, default='blog_post', tracking=True,
        help='The type of content to generate. Each type has different formatting and structure optimized for its purpose.')

    # === Source Information ===
    source_keywords = fields.Char('Target Keywords',
        help='Comma-separated keywords to target in the content')
    source_topic = fields.Text('Topic/Brief',
        help='Description of what the content should be about')
    source_product_id = fields.Many2one('product.template', 'Source Product',
        help='Generate content based on this product')
    source_text = fields.Text('Source Content',
        help='Existing content to optimize or use as reference')

    # === Generation Settings ===
    tone = fields.Selection([
        ('professional', 'Professional'),
        ('casual', 'Casual'),
        ('technical', 'Technical'),
        ('persuasive', 'Persuasive'),
        ('conversational', 'Conversational'),
    ], string='Tone', default='professional',
        help='The writing style and voice for the generated content. Professional is formal and business-like, Casual is friendly and relaxed, Technical is detailed and precise, Persuasive is sales-oriented, and Conversational is engaging and personal.')

    target_word_count = fields.Selection([
        ('short', 'Short (300-500 words)'),
        ('medium', 'Medium (500-1000 words)'),
        ('long', 'Long (1000-2000 words)'),
    ], string='Target Length', default='medium',
        help='The approximate length of the generated content. Longer content typically ranks better for SEO but requires more tokens.')

    language_id = fields.Many2one('res.lang', 'Language',
        default=lambda self: self._default_language(),
        help='The language in which the content will be generated. The AI will write in this language.')

    template_id = fields.Many2one('otk.seo.template', 'Content Template',
        domain="[('content_type', '=', content_type)]",
        help='A predefined template that includes system prompts, formatting rules, and default settings. Templates help ensure consistent content structure and style.')

    brand_voice_id = fields.Many2one('otk.seo.brand.voice', 'Brand Voice',
        default=lambda self: self.env['otk.seo.brand.voice'].get_default_voice(),
        help='Brand voice guidelines to apply to generated content. Defines tone, style, vocabulary, and other brand-specific rules.')

    # === References ===
    reference_ids = fields.Many2many('otk.seo.reference',
        'otk_seo_content_reference_rel', 'content_id', 'reference_id',
        string='References',
        domain="['|', ('content_type', '=', content_type), ('content_type', '=', 'all')]",
        help='Reference materials to provide context and inspiration for content generation. Max 5 references.')

    # === Generated Content ===
    generated_title = fields.Char('Generated Title', translate=True,
        help='The AI-generated title optimized for SEO. You can edit this before publishing.')
    generated_subtitle = fields.Char('Generated Subtitle', translate=True,
        help='A subtitle or tagline generated by the AI to complement the main title.')
    generated_content = fields.Html('Generated Content', sanitize=True, translate=True,
        help='The main body of the AI-generated content in HTML format. This includes headings, paragraphs, lists, and other formatting.')
    generated_teaser = fields.Text('Generated Teaser', translate=True,
        help='A short excerpt or summary of the content, typically used for blog post previews and social sharing.')
    generated_keywords = fields.Char('Suggested Keywords', translate=True,
        help='Additional keywords suggested by the AI based on the generated content. Use these to improve your SEO strategy.')

    # Note: SEO fields come from website.seo.metadata:
    # - website_meta_title
    # - website_meta_description
    # - website_meta_keywords
    # - website_meta_og_img
    # - seo_name

    # === SEO Analysis ===
    seo_score = fields.Integer('SEO Score', compute='_compute_seo_score', store=True,
        help='Overall SEO score from 0-100 based on title length, meta description, keyword density, content length, header structure, and image optimization.')
    keyword_density = fields.Float('Keyword Density %', digits=(5, 2),
        help='Percentage of target keywords in the content. Optimal range is 1-2.5%. Too low means keywords are underused; too high may be seen as keyword stuffing.')
    readability_score = fields.Char('Readability',
        help='Assessment of how easy the content is to read, based on sentence length, vocabulary complexity, and structure.')
    seo_analysis = fields.Text('SEO Analysis Details',
        help='Detailed JSON breakdown of SEO scoring for each factor: title, meta description, keywords, content length, headers, and images.')
    seo_analysis_html = fields.Html('SEO Analysis Display', compute='_compute_seo_analysis_html',
        sanitize=False, help='User-friendly display of SEO analysis with actionable advice.')
    seo_improvements = fields.Text('Improvement Suggestions', compute='_compute_seo_analysis_html',
        help='List of actionable improvements to boost SEO score.')
    word_count = fields.Integer('Word Count', compute='_compute_word_count', store=True,
        help='Total number of words in the generated content. Longer content (1000+ words) typically performs better in search rankings.')

    # === Meta Length Indicators ===
    meta_title_length = fields.Integer('Title Length', compute='_compute_meta_lengths',
        help='Character count of the meta title.')
    meta_desc_length = fields.Integer('Description Length', compute='_compute_meta_lengths',
        help='Character count of the meta description.')
    meta_title_status = fields.Selection([
        ('short', 'Too Short'),
        ('optimal', 'Optimal'),
        ('long', 'Too Long'),
    ], string='Title Status', compute='_compute_meta_lengths',
        help='Whether the meta title length is optimal for SEO.')
    meta_desc_status = fields.Selection([
        ('short', 'Too Short'),
        ('optimal', 'Optimal'),
        ('long', 'Too Long'),
    ], string='Description Status', compute='_compute_meta_lengths',
        help='Whether the meta description length is optimal for SEO.')

    # === Images ===
    image_ids = fields.One2many('otk.seo.content.image', 'content_id', 'Generated Images',
        help='AI-generated images for this content. Images are generated asynchronously and will appear here when ready.')
    image_count = fields.Integer('Image Count', compute='_compute_image_count',
        help='Total number of images associated with this content.')
    images_pending = fields.Boolean('Images Pending', compute='_compute_images_pending',
        help='Indicates if there are images still being generated or waiting to be processed.')
    has_insertable_images = fields.Boolean('Has Insertable Images', compute='_compute_has_insertable_images',
        help='True if there are completed non-cover images that have not yet been inserted into content.')
    requested_image_count = fields.Integer('Requested Images', default=1,
        help='Number of images to generate for this content. The first image will be used as the cover image.')
    image_style = fields.Selection([
        ('photorealistic', 'Photorealistic'),
        ('illustration', 'Illustration'),
        ('digital_art', 'Digital Art'),
        ('watercolor', 'Watercolor'),
        ('sketch', 'Sketch'),
        ('3d_render', '3D Render'),
    ], string='Image Style', default='photorealistic',
        help='Visual style for generated images. Applied to all images created for this content.')

    # === Workflow ===
    state = fields.Selection([
        ('draft', 'Draft'),
        ('generating', 'Generating'),
        ('images_pending', 'Waiting for Images'),
        ('review', 'Review'),
        ('approved', 'Approved'),
        ('published', 'Published'),
        ('archived', 'Archived'),
    ], string='Status', default='draft', tracking=True, copy=False, index=True,
        help='Current status in the content workflow. Draft: initial state; Generating: AI is creating content; Waiting for Images: content done, images processing; Review: ready for review; Approved: approved for publishing; Published: blog post created; Archived: no longer active.')

    # === Output ===
    blog_post_id = fields.Many2one('blog.post', 'Created Blog Post', readonly=True, copy=False,
        help='The blog post created from this content. Click to view or edit the published post.')
    blog_id = fields.Many2one('blog.blog', 'Target Blog',
        help='The blog where the post will be published. If not set, the default blog will be used.')

    # === Batch Processing ===
    batch_id = fields.Many2one('otk.seo.content.batch', 'Batch Job', ondelete='set null',
        help='The batch job that created this content, if generated as part of a bulk operation.')

    # === Tracking ===
    token_cost = fields.Integer('Tokens Used', readonly=True, store=True,
        help='Number of API tokens consumed for generating this content. This affects your O\'Toolkit credit balance.')
    generation_time = fields.Float('Generation Time (s)', readonly=True, store=True,
        help='Time taken to generate the content in seconds. Useful for performance monitoring.')
    error_message = fields.Text('Error Message',
        help='If generation failed, this contains the error details. Check this to troubleshoot issues.')
    api_task_id = fields.Char('API Task ID',
        help='Internal identifier for tracking the generation task in the O\'Toolkit API.')

    # === Retry Logic ===
    retry_count = fields.Integer('Retry Count', default=0,
        help='Number of retry attempts made for this content generation.')
    max_retries = fields.Integer('Max Retries', default=3,
        help='Maximum number of retry attempts before permanently failing.')
    next_retry_at = fields.Datetime('Next Retry At',
        help='Scheduled time for next retry attempt.')
    generating_since = fields.Datetime('Generating Since',
        help='Timestamp of when generation started, for stuck task detection.')

    # === Content Freshness ===
    last_refreshed_date = fields.Date('Last Refreshed',
        default=fields.Date.today,
        help='The date when this content was last reviewed or updated. Used to track content freshness.')
    refresh_threshold_days = fields.Integer('Refresh Threshold (days)',
        default=180,
        help='Number of days after which content should be reviewed for freshness. Default is 180 days (6 months).')
    is_stale = fields.Boolean('Needs Refresh', compute='_compute_freshness', store=True,
        help='Indicates if the content has exceeded its refresh threshold and should be reviewed.')
    days_since_refresh = fields.Integer('Days Since Refresh', compute='_compute_freshness', store=True,
        help='Number of days since the content was last refreshed.')
    refresh_alert_sent = fields.Boolean('Refresh Alert Sent', default=False,
        help='Indicates if a refresh alert has already been sent for this content.')

    # === Version History ===
    version_ids = fields.One2many('otk.seo.content.version', 'content_id', 'Versions', copy=False,
        help='Previous versions of this content. You can restore any version if needed.')
    current_version = fields.Integer('Current Version', default=1, copy=False,
        help='The current version number. Incremented each time content is regenerated or restored.')

    @api.constrains('requested_image_count')
    def _check_image_count_range(self):
        for record in self:
            if not (0 <= record.requested_image_count <= 10):
                raise ValidationError(_('Image count must be between 0 and 10.'))

    def _default_language(self):
        """Get default language for content generation."""
        lang_code = self.env.lang or 'en_US'
        lang = self.env['res.lang']._lang_get(lang_code)
        return lang.id if lang else False

    @api.onchange('template_id')
    def _onchange_template_id_references(self):
        """Inherit references from template when selected."""
        if self.template_id and self.template_id.reference_ids:
            # Add template references (user can still modify)
            self.reference_ids = [(6, 0, self.template_id.reference_ids.ids)]

    @api.depends('last_refreshed_date', 'refresh_threshold_days')
    def _compute_freshness(self):
        """Compute content freshness based on last refresh date."""
        today = fields.Date.today()
        for record in self:
            if record.last_refreshed_date:
                delta = today - record.last_refreshed_date
                record.days_since_refresh = delta.days
                record.is_stale = delta.days >= (record.refresh_threshold_days or 180)
            else:
                record.days_since_refresh = 0
                record.is_stale = False

    @api.depends('generated_content')
    def _compute_word_count(self):
        for record in self:
            if record.generated_content:
                text = html2plaintext(record.generated_content)
                record.word_count = len(text.split())
            else:
                record.word_count = 0

    @api.depends('website_meta_title', 'website_meta_description')
    def _compute_meta_lengths(self):
        for record in self:
            # Title length and status
            title_len = len(record.website_meta_title or '')
            record.meta_title_length = title_len
            if title_len == 0:
                record.meta_title_status = 'short'
            elif 30 <= title_len <= 60:
                record.meta_title_status = 'optimal'
            elif title_len < 30:
                record.meta_title_status = 'short'
            else:
                record.meta_title_status = 'long'

            # Description length and status
            desc_len = len(record.website_meta_description or '')
            record.meta_desc_length = desc_len
            if desc_len == 0:
                record.meta_desc_status = 'short'
            elif 120 <= desc_len <= 155:
                record.meta_desc_status = 'optimal'
            elif desc_len < 120:
                record.meta_desc_status = 'short'
            else:
                record.meta_desc_status = 'long'

    @api.depends('image_ids')
    def _compute_image_count(self):
        for record in self:
            record.image_count = len(record.image_ids)

    @api.depends('image_ids.state')
    def _compute_images_pending(self):
        for record in self:
            record.images_pending = any(
                img.state in ('pending', 'processing')
                for img in record.image_ids
            )

    @api.depends('image_ids', 'image_ids.state', 'image_ids.is_cover', 'image_ids.inserted_in_content')
    def _compute_has_insertable_images(self):
        for record in self:
            record.has_insertable_images = any(
                img.state == 'done' and not img.is_cover and not img.inserted_in_content
                for img in record.image_ids
            )

    @api.depends('generated_content', 'website_meta_title', 'website_meta_description',
                 'website_meta_keywords', 'source_keywords', 'generated_title',
                 'image_ids.state', 'image_ids.alt_text')
    def _compute_seo_score(self):
        for record in self:
            score = 0
            analysis = {}

            # Title optimization (15 points)
            if record.website_meta_title:
                title_len = len(record.website_meta_title)
                if 30 <= title_len <= 60:
                    score += 15
                    analysis['title'] = {'score': 15, 'status': 'optimal', 'length': title_len}
                elif 20 <= title_len < 30 or 60 < title_len <= 70:
                    score += 8
                    analysis['title'] = {'score': 8, 'status': 'acceptable', 'length': title_len}
                else:
                    analysis['title'] = {'score': 0, 'status': 'needs_work', 'length': title_len}
            else:
                analysis['title'] = {'score': 0, 'status': 'missing'}

            # Meta description (15 points)
            if record.website_meta_description:
                desc_len = len(record.website_meta_description)
                if 120 <= desc_len <= 155:
                    score += 15
                    analysis['meta_desc'] = {'score': 15, 'status': 'optimal', 'length': desc_len}
                elif 70 <= desc_len < 120 or 155 < desc_len <= 160:
                    score += 8
                    analysis['meta_desc'] = {'score': 8, 'status': 'acceptable', 'length': desc_len}
                else:
                    analysis['meta_desc'] = {'score': 0, 'status': 'needs_work', 'length': desc_len}
            else:
                analysis['meta_desc'] = {'score': 0, 'status': 'missing'}

            # Meta keywords (10 points)
            if record.website_meta_keywords:
                keywords = [k.strip() for k in record.website_meta_keywords.split(',') if k.strip()]
                keyword_count = len(keywords)
                if 3 <= keyword_count <= 10:
                    score += 10
                    analysis['meta_keywords'] = {'score': 10, 'status': 'optimal', 'count': keyword_count}
                elif 1 <= keyword_count < 3 or 10 < keyword_count <= 15:
                    score += 5
                    analysis['meta_keywords'] = {'score': 5, 'status': 'acceptable', 'count': keyword_count}
                else:
                    analysis['meta_keywords'] = {'score': 0, 'status': 'too_many', 'count': keyword_count}
            else:
                analysis['meta_keywords'] = {'score': 0, 'status': 'missing'}

            # Cache plaintext conversion (used by multiple checks below)
            _plain_text = html2plaintext(record.generated_content) if record.generated_content else ''
            _plain_text_lower = _plain_text.lower() if _plain_text else ''
            _plain_word_count = len(_plain_text.split()) if _plain_text else 0

            # Keyword usage in content (20 points)
            if record.source_keywords and _plain_text:
                keywords = [k.strip().lower() for k in record.source_keywords.split(',')]
                keyword_count = sum(_plain_text_lower.count(kw) for kw in keywords if kw)

                if _plain_word_count > 0:
                    density = (keyword_count / _plain_word_count) * 100
                    record.keyword_density = round(density, 2)

                    if 1 <= density <= 2.5:
                        score += 20
                        analysis['keywords'] = {'score': 20, 'status': 'optimal', 'density': density}
                    elif 0.5 <= density < 1 or 2.5 < density <= 3:
                        score += 10
                        analysis['keywords'] = {'score': 10, 'status': 'acceptable', 'density': density}
                    else:
                        analysis['keywords'] = {'score': 0, 'status': 'needs_work', 'density': density}
                else:
                    analysis['keywords'] = {'score': 0, 'status': 'no_content'}
            else:
                analysis['keywords'] = {'score': 0, 'status': 'missing'}

            # Content length (20 points)
            if _plain_text:
                wc = _plain_word_count

                if wc >= 1000:
                    score += 20
                    analysis['content_length'] = {'score': 20, 'status': 'optimal', 'words': wc}
                elif wc >= 500:
                    score += 15
                    analysis['content_length'] = {'score': 15, 'status': 'good', 'words': wc}
                elif wc >= 300:
                    score += 10
                    analysis['content_length'] = {'score': 10, 'status': 'acceptable', 'words': wc}
                else:
                    analysis['content_length'] = {'score': 0, 'status': 'too_short', 'words': wc}
            else:
                analysis['content_length'] = {'score': 0, 'status': 'no_content'}

            # Header structure (10 points)
            if record.generated_content:
                content_lower = record.generated_content.lower()
                has_h2 = '<h2' in content_lower
                has_h3 = '<h3' in content_lower

                if has_h2 and has_h3:
                    score += 10
                    analysis['headers'] = {'score': 10, 'status': 'optimal', 'h2': has_h2, 'h3': has_h3}
                elif has_h2:
                    score += 5
                    analysis['headers'] = {'score': 5, 'status': 'acceptable', 'h2': has_h2, 'h3': has_h3}
                else:
                    analysis['headers'] = {'score': 0, 'status': 'missing', 'h2': has_h2, 'h3': has_h3}
            else:
                analysis['headers'] = {'score': 0, 'status': 'no_content'}

            # Images (10 points)
            done_images = record.image_ids.filtered(lambda i: i.state == 'done')
            images_with_alt = done_images.filtered(lambda i: i.alt_text)

            if images_with_alt:
                score += 10
                analysis['images'] = {'score': 10, 'status': 'optimal', 'count': len(done_images), 'with_alt': len(images_with_alt)}
            elif done_images:
                score += 5
                analysis['images'] = {'score': 5, 'status': 'missing_alt', 'count': len(done_images), 'with_alt': 0}
            else:
                analysis['images'] = {'score': 0, 'status': 'no_images', 'count': 0}

            record.seo_score = score
            record.seo_analysis = analysis  # fields.Json expects a dict, not a string

    @api.depends('seo_analysis', 'seo_score')
    def _compute_seo_analysis_html(self):
        """Generate user-friendly HTML display of SEO analysis."""
        for record in self:
            if not record.seo_analysis:
                record.seo_analysis_html = '''
                    <div class="text-muted text-center p-4">
                        <i class="fa fa-info-circle fa-2x mb-2"></i>
                        <p>Generate content to see SEO analysis.</p>
                    </div>
                '''
                record.seo_improvements = ''
                continue

            # Parse analysis
            analysis = ast.literal_eval(record.seo_analysis or "{}")

            score = record.seo_score or 0
            improvements = []

            # Build HTML
            html_parts = []

            # Score overview card
            score_color = 'success' if score >= 70 else ('warning' if score >= 40 else 'danger')
            score_icon = 'check-circle' if score >= 70 else ('exclamation-triangle' if score >= 40 else 'times-circle')
            score_desc = record._get_score_description(score)

            html_parts.append(f'''
                <div class="alert alert-{score_color} d-flex align-items-center mb-4" role="alert">
                    <div class="me-3" style="font-size: 2.5rem; font-weight: bold; min-width: 80px; text-align: center;">
                        {score}<small style="font-size: 0.5em">/100</small>
                    </div>
                    <div>
                        <strong><i class="fa fa-{score_icon}"></i> {score_desc['title']}</strong>
                        <p class="mb-0 small">{score_desc['message']}</p>
                    </div>
                </div>
            ''')

            # Analysis sections
            sections = [
                ('title', 'Meta Title', 'fa-heading', 15),
                ('meta_desc', 'Meta Description', 'fa-align-left', 15),
                ('meta_keywords', 'Meta Keywords', 'fa-tags', 10),
                ('keywords', 'Keyword Density', 'fa-key', 20),
                ('content_length', 'Content Length', 'fa-file-text-o', 20),
                ('headers', 'Header Structure', 'fa-list-ol', 10),
                ('images', 'Images', 'fa-image', 10),
            ]

            html_parts.append('<div class="row">')

            for key, label, icon, max_score in sections:
                section_data = analysis.get(key, {})
                section_score = section_data.get('score', 0)

                # Determine styling
                if section_score == max_score:
                    bg_class = 'bg-success-subtle border-success'
                    text_class = 'text-success'
                    status_icon = 'fa-check-circle'
                elif section_score > 0:
                    bg_class = 'bg-warning-subtle border-warning'
                    text_class = 'text-warning'
                    status_icon = 'fa-exclamation-circle'
                else:
                    bg_class = 'bg-danger-subtle border-danger'
                    text_class = 'text-danger'
                    status_icon = 'fa-times-circle'

                # Get advice
                advice = record._get_seo_advice(key, section_data, max_score)
                if advice and section_score < max_score:
                    improvements.append(advice)

                # Get details
                details = record._get_section_details(key, section_data)

                html_parts.append(f'''
                    <div class="col-md-6 col-lg-4 mb-3">
                        <div class="card h-100 border {bg_class}">
                            <div class="card-body p-3">
                                <div class="d-flex justify-content-between align-items-start mb-2">
                                    <span class="fw-bold">
                                        <i class="fa {icon} me-1"></i> {label}
                                    </span>
                                    <span class="badge bg-{'success' if section_score == max_score else ('warning' if section_score > 0 else 'danger')}">
                                        {section_score}/{max_score}
                                    </span>
                                </div>
                                <div class="{text_class} small">
                                    <i class="fa {status_icon}"></i>
                                    {advice if advice else '<span class="text-success">Excellent!</span>'}
                                </div>
                                {f'<div class="text-muted small mt-2 border-top pt-2">{details}</div>' if details else ''}
                            </div>
                        </div>
                    </div>
                ''')

            html_parts.append('</div>')

            record.seo_analysis_html = ''.join(html_parts)
            record.seo_improvements = '\n'.join(f"• {imp}" for imp in improvements) if improvements else ''

    def _get_score_description(self, score):
        """Get title and message based on score."""
        if score >= 80:
            return {
                'title': 'Excellent SEO',
                'message': 'Your content is well-optimized for search engines. Great job!'
            }
        elif score >= 60:
            return {
                'title': 'Good SEO',
                'message': 'Your content is fairly optimized but has room for improvement.'
            }
        elif score >= 40:
            return {
                'title': 'Needs Improvement',
                'message': 'Your content needs work to rank well. Follow the suggestions below.'
            }
        else:
            return {
                'title': 'Poor SEO',
                'message': 'Significant improvements needed. Address the issues highlighted below.'
            }

    def _get_seo_advice(self, key, data, max_score):
        """Get actionable advice for each SEO factor."""
        status = data.get('status', 'missing')
        current_score = data.get('score', 0)

        if current_score == max_score:
            return None  # No advice needed

        advice_map = {
            'title': {
                'missing': 'Add a meta title (30-60 characters recommended)',
                'needs_work': f"Optimize title length ({data.get('length', 0)} chars) - aim for 30-60 characters",
                'acceptable': f"Title is {data.get('length', 0)} chars - optimal is 30-60 characters",
            },
            'meta_desc': {
                'missing': 'Add a meta description (120-155 characters recommended)',
                'needs_work': f"Adjust description length ({data.get('length', 0)} chars) - aim for 120-155 characters",
                'acceptable': f"Description is {data.get('length', 0)} chars - optimal is 120-155 characters",
            },
            'meta_keywords': {
                'missing': 'Add 3-10 meta keywords separated by commas',
                'too_many': f"Reduce keywords from {data.get('count', 0)} to 3-10 for best results",
                'acceptable': f"Add more keywords (currently {data.get('count', 0)}) - aim for 3-10",
            },
            'keywords': {
                'missing': 'Add target keywords to analyze density',
                'no_content': 'Generate content to analyze keyword usage',
                'needs_work': f"Adjust keyword density ({data.get('density', 0):.1f}%) - optimal is 1-2.5%",
                'acceptable': f"Keyword density ({data.get('density', 0):.1f}%) is acceptable - optimal is 1-2.5%",
            },
            'content_length': {
                'no_content': 'Generate content first',
                'too_short': f"Expand content from {data.get('words', 0)} words - aim for 1000+ words",
                'acceptable': f"Content has {data.get('words', 0)} words - 1000+ is optimal for SEO",
                'good': f"Good length ({data.get('words', 0)} words) - 1000+ is optimal",
            },
            'headers': {
                'no_content': 'Generate content first',
                'missing': 'Add H2 and H3 headers to structure your content',
                'acceptable': 'Add H3 subheaders for better content structure',
            },
            'images': {
                'no_images': 'Add images to improve engagement and SEO',
                'missing_alt': f"Add alt text to your {data.get('count', 0)} image(s) for accessibility and SEO",
            },
        }

        return advice_map.get(key, {}).get(status)

    def _get_section_details(self, key, data):
        """Get additional details for display."""
        parts = []

        if key == 'title' and 'length' in data:
            parts.append(f"<strong>Length:</strong> {data['length']} characters")
        elif key == 'meta_desc' and 'length' in data:
            parts.append(f"<strong>Length:</strong> {data['length']} characters")
        elif key == 'meta_keywords' and 'count' in data:
            parts.append(f"<strong>Count:</strong> {data['count']} keywords")
        elif key == 'keywords' and 'density' in data:
            parts.append(f"<strong>Density:</strong> {data['density']:.2f}%")
        elif key == 'content_length' and 'words' in data:
            parts.append(f"<strong>Word count:</strong> {data['words']}")
        elif key == 'headers':
            h2 = '✓' if data.get('h2') else '✗'
            h3 = '✓' if data.get('h3') else '✗'
            parts.append(f"<strong>H2:</strong> {h2} &nbsp; <strong>H3:</strong> {h3}")
        elif key == 'images':
            count = data.get('count', 0)
            with_alt = data.get('with_alt', 0)
            parts.append(f"<strong>Images:</strong> {count} ({with_alt} with alt text)")

        return ' '.join(parts)

    # === API Methods ===
    def _get_api_config(self):
        """Get API configuration."""
        ICP = self.env['ir.config_parameter'].sudo()
        base_url = ICP.get_param('otoolkit.api.endpoint', '')
        api_key = ICP.get_param('otoolkit_api_key', '')

        if not api_key:
            raise UserError(_("Please configure your O'Toolkit API key in Settings > O'Toolkit."))
        if not base_url:
            raise UserError(_("O'Toolkit API endpoint is not configured."))

        return base_url, api_key

    @api.model
    def _fetch_pricing_config(self, force=False):
        """Fetch pricing config from O'Toolkit API and cache in ir.config_parameter.

        Cached for 1 hour. Returns dict with 'seo_content' and 'seo_content_image' keys,
        each containing 'rate' and 'min_cost'.
        """
        ICP = self.env['ir.config_parameter'].sudo()
        cache_key = 'otoolkit_seo_content.pricing_cache'
        cache_ts_key = 'otoolkit_seo_content.pricing_cache_ts'

        # Check cache (1 hour TTL)
        if not force:
            cached = ICP.get_param(cache_key, '')
            cache_ts = float(ICP.get_param(cache_ts_key, '0'))
            if cached and (time.time() - cache_ts) < 3600:
                try:
                    return json.loads(cached)
                except (json.JSONDecodeError, ValueError):
                    pass

        # Fetch from API
        try:
            base_url = ICP.get_param('otoolkit.api.endpoint', '')
            if not base_url:
                return {}
            url = f"{base_url.rstrip('/')}/api/seo-content/pricing/"
            response = requests.get(url, timeout=10)
            if response.status_code == 200:
                data = response.json()
                ICP.set_param(cache_key, json.dumps(data))
                ICP.set_param(cache_ts_key, str(time.time()))
                _logger.info("SEO pricing config refreshed from API: %s", data)
                return data
        except Exception as e:
            _logger.warning("Failed to fetch pricing config: %s", e)

        return {}

    @api.model
    def get_pricing_estimates(self):
        """Return estimated O'Token costs for content and images.

        Uses pre-computed estimates from the API pricing endpoint,
        so all pricing logic lives server-side and can be tuned
        without updating the Odoo module.
        """
        pricing = self._fetch_pricing_config()
        estimates = pricing.get('estimates')

        if estimates:
            return {
                'text': estimates.get('text', {}),
                'image': estimates.get('image', 0),
            }

        # Fallback if API doesn't return estimates (old API version)
        return {
            'text': {'short': 200, 'medium': 440, 'long': 850},
            'image': 250,
        }

    def _call_api(self, endpoint, payload, timeout=180):
        """Make API call to O'Toolkit backend."""
        base_url, api_key = self._get_api_config()

        headers = {
            'Content-Type': 'application/json',
            'Odoo-Api-Key': api_key,
        }

        url = f"{base_url.rstrip('/')}{endpoint}"

        try:
            response = requests.post(
                url,
                headers=headers,
                json=payload,
                timeout=timeout
            )

            _logger.info(f"API Response: {response.status_code} - {response.text[:500] if response.text else 'empty'}")

            if response.status_code == 400:
                try:
                    error_data = response.json()
                except (ValueError, json.JSONDecodeError):
                    raise UserError(_("API Error (400): %s") % response.text[:500])

                if error_data.get('type') == 'insufficient_funds':
                    raise UserError(_("Insufficient tokens. Please add more credits to your O'Toolkit account."))
                # Handle both 'error' and 'detail' keys
                error_msg = error_data.get('error') or error_data.get('detail') or 'Unknown error'
                raise UserError(_("API Error: %s") % error_msg)

            if response.status_code == 401:
                raise UserError(_("Invalid API key. Please check your O'Toolkit configuration."))

            if response.status_code >= 400:
                raise UserError(_("API Error (%s): %s") % (response.status_code, response.text[:500]))

            response.raise_for_status()

            try:
                return response.json()
            except (ValueError, json.JSONDecodeError):
                raise UserError(_("Invalid response from API: %s") % response.text[:200])

        except requests.exceptions.Timeout:
            raise UserError(_("Request timed out. Please try again."))
        except requests.exceptions.ConnectionError:
            raise UserError(_("Could not connect to O'Toolkit API. Please check your internet connection."))
        except requests.exceptions.RequestException as e:
            _logger.error(f"API Error: {e}")
            raise UserError(_("API request failed: %s") % str(e))

    # === Input Validation ===
    def _validate_generation_inputs(self):
        """Validate inputs before sending to API to prevent abuse and errors."""
        self.ensure_one()
        errors = []

        # Validate keywords length
        if self.source_keywords and len(self.source_keywords) > MAX_KEYWORDS_LENGTH:
            errors.append(_("Keywords are too long (max %d characters, got %d).") % (
                MAX_KEYWORDS_LENGTH, len(self.source_keywords)))

        # Validate topic length
        if self.source_topic and len(self.source_topic) > MAX_TOPIC_LENGTH:
            errors.append(_("Topic/brief is too long (max %d characters, got %d).") % (
                MAX_TOPIC_LENGTH, len(self.source_topic)))

        # Validate source text length
        if self.source_text and len(self.source_text) > MAX_SOURCE_TEXT_LENGTH:
            errors.append(_("Source text is too long (max %d characters, got %d).") % (
                MAX_SOURCE_TEXT_LENGTH, len(self.source_text)))

        # Validate required fields - accept any valid source input
        has_valid_source = bool(
            self.source_keywords or self.source_topic
            or self.source_text or self.source_product_id
        )
        if not has_valid_source:
            errors.append(_("Please provide keywords, a topic, existing content, or a product."))

        # Validate requested image count
        if self.requested_image_count < 0 or self.requested_image_count > 10:
            errors.append(_("Image count must be between 0 and 10."))

        # Validate references count
        if len(self.reference_ids) > MAX_REFERENCES:
            errors.append(_("Maximum %d references allowed (got %d).") % (
                MAX_REFERENCES, len(self.reference_ids)))

        if errors:
            raise UserError('\n'.join(errors))

    # === Actions ===
    def action_generate(self):
        """Submit content generation request to API (async).

        The content is generated asynchronously. A cron job polls for completion
        and updates the record when ready. Images are generated AFTER content
        is complete to enable content-aware prompts.
        """
        self.ensure_one()

        # Validate inputs before proceeding
        self._validate_generation_inputs()

        self.write({
            'state': 'generating',
            'error_message': False,
            'api_task_id': False,
            'generating_since': fields.Datetime.now(),
        })

        # Prepare payload
        payload = {
            'keywords': self.source_keywords or '',
            'topic': self.source_topic or '',
            'content_type': self.content_type,
            'tone': self.tone,
            'word_count': self.target_word_count,
            'language': self.language_id.code if self.language_id else 'en_US',
            'odoo_user_id': self.env.uid,
            'options': {
                'include_toc': self.template_id.include_toc if self.template_id else False,
                'include_faq': self.template_id.include_faq if self.template_id else False,
                'header_structure': self.template_id.header_structure if self.template_id else 'h2_h3',
            }
        }

        # Add source text if available (optimize existing content)
        if self.source_text:
            payload['source_text'] = self.source_text

        # Add product data if available
        if self.source_product_id:
            payload['product'] = {
                'name': self.source_product_id.name,
                'description': self.source_product_id.description or '',
                'price': self.source_product_id.list_price,
                'category': self.source_product_id.categ_id.complete_name if self.source_product_id.categ_id else '',
            }

        # Add template prompts if available
        if self.template_id:
            payload['template'] = {
                'system_prompt': self.template_id.system_prompt,
                'content_prompt': self.template_id.content_prompt_template,
            }

        # Add brand voice guidelines if available
        if self.brand_voice_id:
            payload['brand_voice'] = self.brand_voice_id.get_prompt_instructions()

        # Add references if available
        if self.reference_ids:
            references_payload = []
            for ref in self.reference_ids[:MAX_REFERENCES]:
                ref_data = ref.get_api_payload()
                if ref_data.get('content'):  # Only include non-empty references
                    references_payload.append(ref_data)
            if references_payload:
                payload['references'] = references_payload

        try:
            result = self._call_api('/api/seo-content/generate/', payload)

            if result.get('success') and result.get('task_id'):
                # Store task ID for polling
                self.write({
                    'api_task_id': str(result['task_id']),
                })
                _logger.info(f"Content generation task submitted for {self.id}: task_id={result['task_id']}")

                return {
                    'type': 'ir.actions.client',
                    'tag': 'display_notification',
                    'params': {
                        'title': _('Generation Started'),
                        'message': _('Content is being generated. You will be notified when ready.'),
                        'type': 'info',
                        'sticky': False,
                    }
                }
            else:
                self.write({
                    'state': 'draft',
                    'error_message': result.get('error', 'Unknown error occurred'),
                })
                raise UserError(_("Content generation failed: %s") % result.get('error', 'Unknown error'))

        except UserError:
            raise
        except Exception as e:
            _logger.exception("Content generation error")
            self.write({
                'state': 'draft',
                'error_message': str(e),
            })
            raise UserError(_("Content generation failed: %s") % str(e))

    def _schedule_retry(self, error_msg):
        """Schedule a retry with exponential backoff, or permanently fail."""
        self.ensure_one()
        backoff_minutes = [1, 5, 15]

        if self.retry_count >= self.max_retries:
            self.write({
                'state': 'draft',
                'error_message': _("Permanently failed after %d retries. Last error: %s") % (
                    self.retry_count, error_msg),
                'generating_since': False,
            })
            _logger.warning("Content %s permanently failed after %d retries", self.id, self.retry_count)
            # Send failure notification
            if self.create_uid and self.create_uid.partner_id:
                self.env['bus.bus']._sendone(
                    self.create_uid.partner_id,
                    'simple_notification',
                    {
                        'title': _("Generation Failed"),
                        'message': _("'%s' failed after %d retries.") % (self.name, self.retry_count),
                        'type': 'warning',
                        'sticky': True,
                    }
                )
            return

        delay = backoff_minutes[min(self.retry_count, len(backoff_minutes) - 1)]
        from datetime import timedelta
        next_retry = fields.Datetime.now() + timedelta(minutes=delay)

        self.write({
            'state': 'draft',
            'retry_count': self.retry_count + 1,
            'next_retry_at': next_retry,
            'error_message': _("Retry %d/%d scheduled. Error: %s") % (
                self.retry_count + 1, self.max_retries, error_msg),
            'generating_since': False,
            'api_task_id': False,
        })
        _logger.info("Content %s retry %d/%d scheduled at %s",
                      self.id, self.retry_count, self.max_retries, next_retry)

    @api.model
    def cron_poll_content_status(self):
        """Cron job to poll status of generating content.

        Checks all content records in 'generating' state with an api_task_id,
        polls the API for task status, and processes completed results.
        Also handles stuck tasks and pending retries.
        """
        from datetime import timedelta
        now = fields.Datetime.now()

        # --- Detect stuck tasks (generating for > 1 hour) ---
        stuck_cutoff = now - timedelta(hours=1)
        stuck_content = self.search([
            ('state', '=', 'generating'),
            ('generating_since', '!=', False),
            ('generating_since', '<', stuck_cutoff),
        ], limit=10)
        for content in stuck_content:
            _logger.warning("Content %s stuck in generating since %s, scheduling retry",
                            content.id, content.generating_since)
            content._schedule_retry(_("Task stuck for over 1 hour"))

        # --- Process pending retries ---
        pending_retries = self.search([
            ('state', '=', 'draft'),
            ('next_retry_at', '!=', False),
            ('next_retry_at', '<=', now),
            ('retry_count', '<', 3),
        ], limit=5)
        for content in pending_retries:
            try:
                _logger.info("Retrying content %s (attempt %d)", content.id, content.retry_count)
                content.write({'next_retry_at': False})
                content.action_generate()
            except Exception as e:
                _logger.error("Retry failed for content %s: %s", content.id, e)
                content._schedule_retry(str(e))

        # --- Check content stuck in images_pending with no active images ---
        images_stuck_cutoff = now - timedelta(hours=2)
        stuck_images_content = self.search([
            ('state', '=', 'images_pending'),
            ('write_date', '<', images_stuck_cutoff),
        ], limit=10)
        for content in stuck_images_content:
            pending_imgs = content.image_ids.filtered(
                lambda i: i.state in ('pending', 'processing')
            )
            if not pending_imgs:
                _logger.info("Content %s stuck in images_pending with no active images, moving to review", content.id)
                content.write({'state': 'review'})

        # --- Poll generating tasks ---
        generating = self.search([
            ('state', '=', 'generating'),
            ('api_task_id', '!=', False),
        ], limit=20)

        if not generating:
            return

        # Batch poll all tasks
        task_ids = []
        task_id_map = {}  # Maps task_id to content record
        for content in generating:
            if content.api_task_id:
                try:
                    task_id_int = int(content.api_task_id)
                    task_ids.append(task_id_int)
                    task_id_map[str(task_id_int)] = content
                except (ValueError, TypeError):
                    _logger.warning(f"Invalid api_task_id for content {content.id}: {content.api_task_id}")
                    content.write({
                        'state': 'draft',
                        'error_message': f'Invalid task ID format: {content.api_task_id}',
                    })

        if not task_ids:
            return

        try:
            base_url, api_key = generating[0]._get_api_config()

            headers = {
                'Content-Type': 'application/json',
                'Odoo-Api-Key': api_key,
            }

            response = requests.post(
                f"{base_url.rstrip('/')}/api/tasks/",
                headers=headers,
                json={'task_ids': task_ids},
                timeout=30
            )

            response.raise_for_status()

            try:
                data = response.json()
            except (ValueError, json.JSONDecodeError):
                _logger.error("Invalid JSON from content tasks API: %s", response.text[:200])
                return

            tasks = data.get('tasks', []) if isinstance(data, dict) else data
            task_result_map = {str(t.get('id')): t for t in tasks if t.get('id')}

            for task_id_str, content in task_id_map.items():
                task = task_result_map.get(task_id_str)
                if not task:
                    continue

                status = task.get('status')

                if status == 'completed':
                    result = task.get('result', {})
                    content._process_completed_content(result)
                elif status == 'failed':
                    error_msg = task.get('result', {}).get('error', 'Generation failed')
                    content._schedule_retry(error_msg)

        except Exception as e:
            _logger.error("Content status poll cron error: %s", e)

    def _process_completed_content(self, result):
        """Process completed content generation result.

        Updates content fields and triggers image generation if requested.
        Images are generated AFTER content to enable content-aware prompts.
        """
        self.ensure_one()

        # Create version before updating
        if self.generated_content:
            self._create_version('Before regeneration')

        # Build keywords string from API response
        keywords_list = result.get('keywords', [])
        keywords_str = ', '.join(keywords_list) if keywords_list else ''

        self.write({
            'generated_title': result.get('title', ''),
            'generated_subtitle': result.get('subtitle', ''),
            'generated_content': result.get('content', ''),
            'generated_teaser': result.get('teaser', ''),
            'website_meta_title': result.get('meta_title', ''),
            'website_meta_description': result.get('meta_description', ''),
            'generated_keywords': keywords_str,
            'website_meta_keywords': keywords_str,
            'token_cost': result.get('cost', 0),
            'api_task_id': False,  # Clear task ID
            'state': 'review' if self.requested_image_count == 0 else 'images_pending',
        })

        # Reset retry fields on success
        self.write({
            'retry_count': 0,
            'next_retry_at': False,
            'generating_since': False,
        })

        _logger.info(f"Content {self.id} generation completed: {self.generated_title}")

        # Send bus notification to content creator
        if self.create_uid and self.create_uid.partner_id:
            self.env['bus.bus']._sendone(
                self.create_uid.partner_id,
                'simple_notification',
                {
                    'title': _("Content Ready!"),
                    'message': _("'%s' is ready for review.") % (self.generated_title or self.name),
                    'type': 'success',
                    'sticky': False,
                }
            )

        # Generate images AFTER content is ready (content-aware prompts)
        if self.requested_image_count > 0:
            self._generate_images()

    def _generate_images(self):
        """Create image generation requests."""
        self.ensure_one()

        # Generate prompts based on content
        prompts = self._generate_image_prompts()

        # Use content's image_style, fallback to template default, then 'photorealistic'
        image_style = self.image_style or (
            self.template_id.default_image_style if self.template_id else 'photorealistic'
        )

        for i, prompt in enumerate(prompts[:self.requested_image_count]):
            self.env['otk.seo.content.image'].create({
                'content_id': self.id,
                'name': f"Image {i + 1}",
                'prompt': prompt,
                'style': image_style,
                'aspect_ratio': '1792x1024' if i == 0 else '1024x1024',  # First image landscape for cover
                'is_cover': i == 0,
                'state': 'pending',
            })

    def _generate_image_prompts(self):
        """Generate image prompts based on content context."""
        prompts = []

        title = self.generated_title or self.name
        keywords = self.source_keywords or ''

        # Extract rich context from generated content
        content_context = self._extract_content_context()

        # Cover image - main theme with full context
        cover_prompt = self._build_cover_prompt(title, keywords, content_context)
        prompts.append(cover_prompt)

        # Generate section-specific prompts for additional images
        if self.generated_content and self.requested_image_count > 1:
            section_prompts = self._generate_section_prompts(title, content_context)
            prompts.extend(section_prompts)

        return prompts

    def _extract_content_context(self):
        """Extract key context from generated content for image prompts."""
        if not self.generated_content:
            return {
                'sections': [],
                'summary': '',
                'word_count': 0,
                'content_type': self.content_type,
                'tone': self.tone,
            }

        content_text = html2plaintext(self.generated_content)

        # Extract H2 headers (main topic sections)
        h2_pattern = r'<h2[^>]*>(.*?)</h2>'
        h2_matches = re.findall(h2_pattern, self.generated_content, re.IGNORECASE | re.DOTALL)
        sections = [html2plaintext(h2).strip() for h2 in h2_matches if h2.strip()]

        # Get first paragraph as summary
        first_para = ''
        para_match = re.search(r'<p[^>]*>(.*?)</p>', self.generated_content, re.IGNORECASE | re.DOTALL)
        if para_match:
            first_para = html2plaintext(para_match.group(1)).strip()[:200]

        # Extract any key points from lists
        list_items = []
        li_pattern = r'<li[^>]*>(.*?)</li>'
        li_matches = re.findall(li_pattern, self.generated_content, re.IGNORECASE | re.DOTALL)
        for li in li_matches[:5]:  # First 5 list items
            item_text = html2plaintext(li).strip()
            if item_text and len(item_text) < 100:
                list_items.append(item_text)

        return {
            'sections': sections[:5],  # Top 5 sections
            'summary': first_para,
            'key_points': list_items,
            'word_count': len(content_text.split()),
            'content_type': self.content_type,
            'tone': self.tone,
        }

    def _build_cover_prompt(self, title, keywords, context):
        """Build a rich, context-aware cover image prompt."""
        prompt_parts = []

        # Tone-based styling prefix
        tone_styles = {
            'professional': 'Professional, polished',
            'casual': 'Friendly, approachable',
            'technical': 'Technical, precise',
            'persuasive': 'Bold, compelling',
            'conversational': 'Warm, engaging',
        }
        tone_style = tone_styles.get(context.get('tone', 'professional'), 'Professional')

        # Start with tone and title
        prompt_parts.append(f"{tone_style} hero image for: {title}.")

        # Add summary context if available
        if context.get('summary'):
            # Truncate summary for prompt
            summary = context['summary'][:150]
            prompt_parts.append(f"Visualizing: {summary}.")

        # Add keywords as themes
        if keywords:
            prompt_parts.append(f"Key themes: {keywords}.")

        # Content type specific styling
        type_styles = {
            'blog_post': "Modern blog header style, engaging and shareable, wide cinematic composition.",
            'product_desc': "Product-focused imagery, clean e-commerce aesthetic, professional lighting.",
            'category_desc': "Category showcase, diverse visual elements representing the category.",
            'landing_page': "Hero banner style, impactful and conversion-focused, bold composition.",
            'social_post': "Social media optimized, eye-catching, vibrant colors.",
        }
        style_desc = type_styles.get(context.get('content_type', 'blog_post'),
                                     "Clean, modern web imagery.")
        prompt_parts.append(style_desc)

        # Quality and style modifiers
        prompt_parts.append("High quality, professionally composed, no text overlays or watermarks.")

        return ' '.join(prompt_parts)

    def _generate_section_prompts(self, main_title, context):
        """Generate prompts for section-specific supporting images."""
        prompts = []
        sections = context.get('sections', [])
        key_points = context.get('key_points', [])
        tone = context.get('tone', 'professional')

        # Generate prompts from H2 sections first
        for i, section in enumerate(sections[:self.requested_image_count - 1]):
            prompt_parts = [
                f"Supporting illustration for: '{section}'.",
                f"Related to main topic: {main_title}.",
            ]

            # Add visual style variation based on position
            if i == 0:
                prompt_parts.append("Conceptual illustration style, clear visual metaphor.")
            elif i == 1:
                prompt_parts.append("Informative diagram style, showing process or relationship.")
            else:
                prompt_parts.append("Supporting visual, complementary to main theme.")

            prompt_parts.append("Clean composition, web-friendly, no text.")
            prompts.append(' '.join(prompt_parts))

        # If we need more prompts, use key points
        remaining_count = (self.requested_image_count - 1) - len(prompts)
        if remaining_count > 0 and key_points:
            for point in key_points[:remaining_count]:
                prompt = (
                    f"Visual representation of: '{point}'. "
                    f"Supporting image for {main_title}. "
                    f"Clean, focused illustration, no text overlay."
                )
                prompts.append(prompt)

        # Fallback generic prompts if still need more
        remaining_count = (self.requested_image_count - 1) - len(prompts)
        for i in range(remaining_count):
            fallback_styles = [
                "Infographic-style supporting visual",
                "Abstract conceptual illustration",
                "Detail-focused supporting image",
            ]
            style = fallback_styles[i % len(fallback_styles)]
            prompt = (
                f"{style} for: {main_title}. "
                f"Professional quality, web-optimized, no text."
            )
            prompts.append(prompt)

        return prompts

    # === Image Integration ===
    def _insert_images_into_content(self):
        """Insert generated images into content body.

        First checks for placeholder format, then falls back to section-based insertion.
        """
        self.ensure_one()

        if not self.generated_content:
            return False

        # Check for placeholder format first
        if 'otk-image-placeholder' in self.generated_content:
            return self._replace_image_placeholders()

        # Fallback: Insert at section breaks
        return self._insert_images_at_sections()

    def _replace_image_placeholders(self):
        """Replace image placeholders with actual generated images."""
        self.ensure_one()

        # Convert to string to work with regex (Html field might be Markup)
        content = str(self.generated_content or '')
        done_images = self.image_ids.filtered(
            lambda i: i.state == 'done' and i.image and not i.is_cover
        ).sorted('sequence')

        if not done_images:
            return False

        # Find and replace placeholders
        placeholder_pattern = r'<figure[^>]*class="[^"]*otk-image-placeholder[^"]*"[^>]*data-position="(\d+)"[^>]*>.*?</figure>'

        def replace_placeholder(match):
            position = int(match.group(1))
            image_index = position - 1  # 0-indexed
            if image_index < len(done_images):
                img = done_images[image_index]
                return self._build_image_html(img)
            return ''  # Remove placeholder if no image

        new_content = re.sub(placeholder_pattern, replace_placeholder, content, flags=re.DOTALL)

        if new_content != content:
            # Mark images as inserted
            done_images.write({'inserted_in_content': True})
            # Use Markup to prevent HTML escaping
            self.generated_content = Markup(new_content)
            return True

        return False

    def _insert_images_at_sections(self):
        """Insert images at section breaks (after H2 headers) when no placeholders exist."""
        self.ensure_one()

        # Convert to string to work with regex (Html field might be Markup)
        content = str(self.generated_content or '')
        done_images = self.image_ids.filtered(
            lambda i: i.state == 'done' and i.image and not i.is_cover and not i.inserted_in_content
        ).sorted('sequence')

        if not done_images:
            return False

        # Find H2 closing tags to insert images after
        h2_pattern = r'(</h2>)'
        matches = list(re.finditer(h2_pattern, content, re.IGNORECASE))

        if not matches:
            # No H2s found, try inserting after first paragraph
            p_match = re.search(r'(</p>)', content, re.IGNORECASE)
            if p_match and done_images:
                img = done_images[0]
                img_html = self._build_image_html(img)
                insert_pos = p_match.end()
                content = content[:insert_pos] + img_html + content[insert_pos:]
                img.inserted_in_content = True
                # Use Markup to prevent HTML escaping
                self.generated_content = Markup(content)
                return True
            return False

        # Insert images after every 2nd H2 (spread them out)
        # For 1 image: after 2nd H2
        # For 2 images: after 2nd and 4th H2
        # etc.
        insert_points = matches[1::2]  # Every other H2, starting from 2nd (index 1)

        if not insert_points:
            insert_points = matches[:1]  # Use first H2 if only one exists

        offset = 0
        inserted_count = 0
        for i, match in enumerate(insert_points):
            if i >= len(done_images):
                break

            img = done_images[i]
            img_html = self._build_image_html(img)
            insert_pos = match.end() + offset
            content = content[:insert_pos] + img_html + content[insert_pos:]
            offset += len(img_html)
            img.inserted_in_content = True
            inserted_count += 1

        if inserted_count > 0:
            # Use Markup to prevent HTML escaping
            self.generated_content = Markup(content)
            return True

        return False

    def _build_image_html(self, image):
        """Build HTML for inserting an image into content."""
        alt_text = image.alt_text or ''
        # Escape HTML special characters in alt text
        alt_text_escaped = alt_text.replace('"', '&quot;').replace('<', '&lt;').replace('>', '&gt;')

        html = f'''
<figure class="otk-generated-image text-center my-4">
    <img src="/web/image/otk.seo.content.image/{image.id}/image"
         alt="{alt_text_escaped}"
         class="img-fluid rounded shadow-sm"
         loading="lazy"
         style="max-width: 100%; height: auto;"/>'''

        if alt_text:
            html += f'''
    <figcaption class="text-muted small mt-2 fst-italic">{alt_text_escaped}</figcaption>'''

        html += '''
</figure>
'''
        return html

    def action_insert_images(self):
        """Manually insert/refresh images in content."""
        self.ensure_one()

        done_images = self.image_ids.filtered(lambda i: i.state == 'done' and i.image)
        if not done_images:
            raise UserError(_("No completed images to insert. Please generate images first."))

        # Reset inserted flag to allow re-insertion
        done_images.filtered(lambda i: not i.is_cover).write({'inserted_in_content': False})

        inserted = self._insert_images_into_content()

        if inserted:
            return {
                'type': 'ir.actions.client',
                'tag': 'display_notification',
                'params': {
                    'title': _('Images Inserted'),
                    'message': _('Images have been inserted into the content.'),
                    'type': 'success',
                    'sticky': False,
                    'next': {'type': 'ir.actions.client', 'tag': 'soft_reload'},
                }
            }
        else:
            return {
                'type': 'ir.actions.client',
                'tag': 'display_notification',
                'params': {
                    'title': _('No Changes'),
                    'message': _('No suitable insertion points found in content.'),
                    'type': 'warning',
                    'sticky': False,
                }
            }

    def action_regenerate(self):
        """Regenerate content."""
        self.ensure_one()
        return self.action_generate()

    def action_approve(self):
        """Approve content for publishing."""
        self.ensure_one()
        self.write({'state': 'approved'})

    def action_back_to_draft(self):
        """Return to draft state for editing."""
        self.ensure_one()
        self.write({'state': 'draft'})

    def action_create_blog_post(self):
        """Create a blog.post from generated content."""
        self.ensure_one()

        # Check if website_blog is installed
        if not self.env['ir.module.module'].search([
            ('name', '=', 'website_blog'),
            ('state', '=', 'installed')
        ]):
            raise UserError(_("Website Blog module is not installed. Please install it first."))

        if not self.blog_id:
            # Get default blog
            self.blog_id = self.env['blog.blog'].search([], limit=1)
            if not self.blog_id:
                raise UserError(_("No blog found. Please create a blog first or select one."))

        # Insert non-cover images into content before creating blog post
        non_cover_images = self.image_ids.filtered(
            lambda i: i.state == 'done' and i.image and not i.is_cover and not i.inserted_in_content
        )
        if non_cover_images:
            self._insert_images_into_content()

        # Prepare cover image
        cover_properties = {}
        cover_image = self.image_ids.filtered(lambda i: i.is_cover and i.state == 'done')[:1]
        if cover_image:
            cover_properties = {
                'background-image': f"url('/web/image/otk.seo.content.image/{cover_image.id}/image')",
                'resize_class': 'o_half_screen_height',
                'opacity': '0.4',
                'background_color_class': 'o_cc3',
            }

        # Create blog post
        blog_post = self.env['blog.post'].create({
            'name': self.generated_title or self.name,
            'subtitle': self.generated_subtitle,
            'content': self.generated_content,
            'teaser_manual': self.generated_teaser,
            'website_meta_title': self.website_meta_title,
            'website_meta_description': self.website_meta_description,
            'website_meta_keywords': self.website_meta_keywords,
            'cover_properties': json.dumps(cover_properties) if cover_properties else False,
            'author_id': self.env.user.partner_id.id,
            'blog_id': self.blog_id.id,
            'is_published': False,
        })

        self.write({
            'blog_post_id': blog_post.id,
            'state': 'published',
        })

        return {
            'type': 'ir.actions.act_window',
            'res_model': 'blog.post',
            'res_id': blog_post.id,
            'view_mode': 'form',
            'target': 'current',
        }

    def action_view_blog_post(self):
        """Open the created blog post."""
        self.ensure_one()
        if not self.blog_post_id:
            raise UserError(_("No blog post has been created yet."))

        return {
            'type': 'ir.actions.act_window',
            'res_model': 'blog.post',
            'res_id': self.blog_post_id.id,
            'view_mode': 'form',
            'target': 'current',
        }

    def action_view_images(self):
        """View generated images."""
        self.ensure_one()
        return {
            'type': 'ir.actions.act_window',
            'res_model': 'otk.seo.content.image',
            'view_mode': 'list,form',
            'domain': [('content_id', '=', self.id)],
            'context': {'default_content_id': self.id},
            'name': _('Generated Images'),
        }

    def action_apply_to_product(self):
        """Apply generated SEO content to the source product."""
        self.ensure_one()

        if not self.source_product_id:
            raise UserError(_("No source product is linked to this content."))

        if self.content_type != 'product_desc':
            raise UserError(_("This action is only available for Product Description content."))

        # Prepare values to update
        vals = {}

        if self.website_meta_title:
            vals['website_meta_title'] = self.website_meta_title
        if self.website_meta_description:
            vals['website_meta_description'] = self.website_meta_description
        if self.website_meta_keywords:
            vals['website_meta_keywords'] = self.website_meta_keywords
        if self.generated_content:
            vals['website_description'] = self.generated_content

        if not vals:
            raise UserError(_("No SEO content to apply. Please generate content first."))

        # Apply to product
        self.source_product_id.write(vals)

        return {
            'type': 'ir.actions.client',
            'tag': 'display_notification',
            'params': {
                'title': _('SEO Applied'),
                'message': _('SEO content has been applied to product "%s".') % self.source_product_id.name,
                'type': 'success',
                'sticky': False,
            }
        }

    # === Dashboard ===
    @api.model
    def get_dashboard_data(self):
        """Return aggregated dashboard data for the SEO content dashboard."""
        Content = self.env['otk.seo.content']

        # Total content
        total_content = Content.search_count([])

        # Average SEO score (using read_group)
        avg_data = Content._read_group(
            [('seo_score', '>', 0)],
            [],
            ['seo_score:avg'],
        )
        avg_seo_score = round(avg_data[0][0], 1) if avg_data else 0

        # Total tokens
        token_data = Content._read_group(
            [],
            [],
            ['token_cost:sum'],
        )
        total_tokens = token_data[0][0] or 0 if token_data else 0

        # Stale content count
        stale_count = Content.search_count([('is_stale', '=', True)])

        # Content by state
        state_data = Content._read_group(
            [],
            ['state'],
            ['__count'],
        )
        content_by_state = {state: count for state, count in state_data}

        # Content by type
        type_data = Content._read_group(
            [],
            ['content_type'],
            ['__count'],
        )
        content_by_type = {ctype: count for ctype, count in type_data}

        # Recent content (last 10)
        recent = Content.search_read(
            [],
            ['name', 'content_type', 'state', 'seo_score', 'create_date'],
            limit=10,
            order='create_date desc',
        )

        return {
            'totalContent': total_content,
            'avgSeoScore': avg_seo_score,
            'totalTokens': total_tokens,
            'staleCount': stale_count,
            'contentByState': content_by_state,
            'contentByType': content_by_type,
            'recentContent': recent,
        }

    # === Version Management ===
    def _create_version(self, note=None):
        """Create a version snapshot before changes."""
        self.ensure_one()

        # Limit versions (configurable via settings)
        ICP = self.env['ir.config_parameter'].sudo()
        max_versions = int(ICP.get_param('otoolkit_seo_content.max_versions', '5'))
        if len(self.version_ids) >= max_versions:
            oldest = self.version_ids.sorted('version_number')[0]
            oldest.unlink()

        self.env['otk.seo.content.version'].create({
            'content_id': self.id,
            'version_number': self.current_version,
            'generated_title': self.generated_title,
            'generated_subtitle': self.generated_subtitle,
            'generated_content': self.generated_content,
            'generated_teaser': self.generated_teaser,
            'website_meta_title': self.website_meta_title,
            'website_meta_description': self.website_meta_description,
            'note': note or f'Version {self.current_version}',
        })

        self.current_version += 1

    def action_restore_version(self):
        """Open wizard to select version to restore."""
        self.ensure_one()
        return {
            'type': 'ir.actions.act_window',
            'res_model': 'otk.seo.content.version',
            'view_mode': 'list',
            'domain': [('content_id', '=', self.id)],
            'name': _('Version History'),
            'target': 'new',
        }

    # === Content Freshness ===
    def action_mark_refreshed(self):
        """Mark content as freshly reviewed/updated."""
        self.ensure_one()
        self.write({
            'last_refreshed_date': fields.Date.today(),
            'refresh_alert_sent': False,
        })
        return {
            'type': 'ir.actions.client',
            'tag': 'display_notification',
            'params': {
                'title': _('Content Refreshed'),
                'message': _('Content has been marked as refreshed.'),
                'type': 'success',
                'sticky': False,
                'next': {'type': 'ir.actions.client', 'tag': 'soft_reload'},
            }
        }

    @api.model
    def cron_check_stale_content(self):
        """Cron job to check for stale content and send alerts."""
        # Find published/approved content that is stale and hasn't had alert sent
        stale_content = self.search([
            ('state', 'in', ['published', 'approved']),
            ('is_stale', '=', True),
            ('refresh_alert_sent', '=', False),
        ])

        for content in stale_content:
            # Create activity for content creator
            content.activity_schedule(
                'mail.mail_activity_data_todo',
                summary=_('Content needs refresh: %s') % content.name,
                note=_('This content was last refreshed %d days ago and may need updating to maintain SEO rankings.') % content.days_since_refresh,
                user_id=content.create_uid.id,
            )
            content.refresh_alert_sent = True

            _logger.info(f"Refresh alert sent for content {content.id}: {content.name}")

        return True

    @api.model
    def action_view_stale_content(self):
        """View all stale content that needs refresh."""
        return {
            'type': 'ir.actions.act_window',
            'name': _('Content Needing Refresh'),
            'res_model': 'otk.seo.content',
            'view_mode': 'list,form',
            'domain': [('is_stale', '=', True)],
            'context': {'search_default_filter_stale': True},
        }


class OtkSeoContentVersion(models.Model):
    _name = 'otk.seo.content.version'
    _description = 'SEO Content Version History'
    _order = 'version_number desc'

    content_id = fields.Many2one('otk.seo.content', required=True, ondelete='cascade',
        help='The parent content record this version belongs to.')
    version_number = fields.Integer('Version', required=True,
        help='Sequential version number for this snapshot.')

    # Snapshot of content at this version
    generated_title = fields.Char('Title',
        help='The title at the time this version was saved.')
    generated_subtitle = fields.Char('Subtitle',
        help='The subtitle at the time this version was saved.')
    generated_content = fields.Html('HTML Content', sanitize=False,
        help='The full HTML content at the time this version was saved.')
    generated_teaser = fields.Text('Teaser',
        help='The teaser text at the time this version was saved.')
    website_meta_title = fields.Char('Meta Title',
        help='The SEO meta title at the time this version was saved.')
    website_meta_description = fields.Text('Meta Description',
        help='The SEO meta description at the time this version was saved.')

    # Metadata
    created_by = fields.Many2one('res.users', default=lambda self: self.env.user,
        help='The user who created this version snapshot.')
    created_at = fields.Datetime(default=fields.Datetime.now,
        help='When this version was saved.')
    note = fields.Char('Version Note',
        help='A brief description of why this version was created (e.g., "Before regeneration", "Manual edit").')

    # Diff preview
    diff_preview = fields.Html('Diff Preview', compute='_compute_diff_preview', sanitize=False,
        help='Shows differences between this version and the current content.')

    @api.depends('generated_content', 'content_id.generated_content')
    def _compute_diff_preview(self):
        for record in self:
            if not record.generated_content or not record.content_id.generated_content:
                record.diff_preview = False
                continue

            old_text = html2plaintext(record.generated_content).splitlines()
            new_text = html2plaintext(record.content_id.generated_content).splitlines()

            diff = difflib.unified_diff(
                old_text, new_text,
                fromfile=_('Version %s') % record.version_number,
                tofile=_('Current'),
                lineterm='',
            )

            diff_lines = []
            for line in diff:
                if line.startswith('+++') or line.startswith('---'):
                    diff_lines.append(f'<div class="o_diff_header">{line}</div>')
                elif line.startswith('@@'):
                    diff_lines.append(f'<div class="o_diff_section">{line}</div>')
                elif line.startswith('+'):
                    diff_lines.append(f'<div class="o_diff_add">{line}</div>')
                elif line.startswith('-'):
                    diff_lines.append(f'<div class="o_diff_remove">{line}</div>')
                else:
                    diff_lines.append(f'<div class="o_diff_context">{line}</div>')

            if diff_lines:
                record.diff_preview = Markup(
                    '<div class="o_seo_diff_view">' + ''.join(diff_lines) + '</div>'
                )
            else:
                record.diff_preview = Markup(
                    '<div class="text-muted text-center py-3">'
                    '<i class="fa fa-check-circle text-success"/> No differences</div>'
                )

    def action_view_diff(self):
        """Open a dialog showing the diff between this version and current content."""
        self.ensure_one()
        return {
            'type': 'ir.actions.act_window',
            'name': _('Version %s - Diff') % self.version_number,
            'res_model': 'otk.seo.content.version',
            'res_id': self.id,
            'view_mode': 'form',
            'view_id': self.env.ref('otoolkit_seo_content.otk_seo_content_version_view_diff').id,
            'target': 'new',
        }

    def action_restore(self):
        """Restore this version to the content."""
        self.ensure_one()

        # Create version of current state first
        self.content_id._create_version(note='Before restore')

        # Restore
        self.content_id.write({
            'generated_title': self.generated_title,
            'generated_subtitle': self.generated_subtitle,
            'generated_content': self.generated_content,
            'generated_teaser': self.generated_teaser,
            'website_meta_title': self.website_meta_title,
            'website_meta_description': self.website_meta_description,
        })

        return {
            'type': 'ir.actions.client',
            'tag': 'display_notification',
            'params': {
                'title': _('Version Restored'),
                'message': _('Content has been restored to version %s.') % self.version_number,
                'type': 'success',
                'next': {'type': 'ir.actions.client', 'tag': 'soft_reload'},
            }
        }
