"""
Report this install to Final Target and apply whatever it sends back.

Run on a schedule from the tenant server:

    0 * * * * cd /home/<user>/public_html/backend && \
      venv/bin/python manage.py ft_checkin --settings=config.settings.production

Requires two settings, normally from .env:

    FT_CONTROL_URL   full URL of the Final Target check-in endpoint
    FT_CONTROL_HOST  optional, the public hostname to send as Host. Use this
                     when the tenant and Final Target share a server, so the
                     call can go straight to 127.0.0.1 and skip the CDN.
    FT_TENANT_SLUG   this tenant's slug as it exists in the Final Target database
    FT_TENANT_KEY    the shared secret from the tenant's Fleet page

Nothing happens if FT_CONTROL_URL is unset, so the master install can carry
this code without ever calling itself.
"""
import hashlib
import json
import os
import re
import sys
import time
import urllib.error
import urllib.parse
import urllib.request

from django.conf import settings
from django.core.files.base import ContentFile
from django.core.management.base import BaseCommand

from apps.ftlink.signing import sign
from apps.ftlink.collect import local_report


class Command(BaseCommand):
    help = 'Report this install to Final Target and apply returned directives.'

    def add_arguments(self, parser):
        parser.add_argument(
            '--dry-run', action='store_true',
            help='Show what would be sent and what came back, change nothing.',
        )
        parser.add_argument(
            '--timeout', type=int, default=20,
            help='Seconds to wait for Final Target before giving up.',
        )

    def handle(self, *args, **options):
        from apps.ftlink.models import JobRun
        url = getattr(settings, 'FT_CONTROL_URL', '')
        slug = getattr(settings, 'FT_TENANT_SLUG', '')
        key = getattr(settings, 'FT_TENANT_KEY', '')
        # When the tenant sits on the same server as Final Target, point
        # FT_CONTROL_URL at localhost and set FT_CONTROL_HOST to the public
        # hostname. The request never leaves the box, so there is no CDN in the
        # way, while Final Target still resolves the right site from the Host.
        control_host = getattr(settings, 'FT_CONTROL_HOST', '')

        if not url:
            self.stdout.write('FT_CONTROL_URL is not set, nothing to do.')
            return
        if not slug or not key:
            self.stderr.write('FT_TENANT_SLUG and FT_TENANT_KEY must both be set.')
            return

        tenant = self._local_tenant()
        payload = local_report(tenant)

        body = json.dumps(payload, separators=(',', ':')).encode('utf-8')
        timestamp = int(time.time())

        if options['dry_run']:
            self.stdout.write('Would send:')
            self.stdout.write(json.dumps(payload, indent=2))

        request = urllib.request.Request(
            url,
            data=body,
            method='POST',
            headers={
                'Content-Type': 'application/json',
                'Accept': 'application/json',
                # A plain urllib request is rejected by Cloudflare's browser
                # integrity check, so identify the agent properly.
                'User-Agent': f'FinalTargetFleet/1.0 (+{slug}; Django ft_checkin)',
                'X-FT-Tenant': slug,
                'X-FT-Timestamp': str(timestamp),
                'X-FT-Signature': sign(key, body, timestamp),
            },
        )

        if control_host:
            # Both are needed: Host for Django's own resolution, and the
            # forwarded header because Final Target runs with
            # USE_X_FORWARDED_HOST behind Apache.
            request.add_header('Host', control_host)
            request.add_header('X-Forwarded-Host', control_host)
            request.add_header('X-Forwarded-Proto', 'https')

        try:
            with urllib.request.urlopen(request, timeout=options['timeout']) as response:
                answer = json.loads(response.read().decode('utf-8'))
        except urllib.error.HTTPError as exc:
            detail = exc.read().decode('utf-8', 'replace')[:300]
            if exc.code in (403, 503) and 'error code' in detail.lower():
                self.stderr.write(
                    f'Blocked before reaching Final Target ({exc.code}): {detail.strip()}\n'
                    f'This is the CDN in front of the panel, not Django. Add a rule that '
                    f'skips bot protection for the path /api/ftlink/ on that hostname.'
                )
            else:
                self.stderr.write(f'Final Target rejected the check-in ({exc.code}): {detail}')
            sys.exit(1)
        except (urllib.error.URLError, ValueError, OSError) as exc:
            self.stderr.write(f'Could not reach Final Target: {exc}')
            sys.exit(1)

        directives = answer.get('directives') or {}
        self.stdout.write(f'Checked in as "{slug}". Received: {json.dumps(directives)}')

        if options['dry_run']:
            self.stdout.write('Dry run, no changes applied.')
            return

        if tenant is None:
            self.stderr.write('No local tenant row found, directives not applied.')
            return

        self._apply(tenant, directives)
        JobRun.record('ft_checkin', note=f'checked in as {slug}')

    def _local_tenant(self):
        """The Tenant row this install serves, preferring the master flag."""
        from apps.tenants.models import Tenant
        return Tenant.objects.filter(is_master=True).first() or Tenant.objects.first()

    # Logos are the one directive that carries a file rather than a value, so
    # everything below is about not trusting it further than it deserves.
    LOGO_MAX_BYTES = 5 * 1024 * 1024
    LOGO_TYPES = {
        'image/png': '.png', 'image/jpeg': '.jpg', 'image/gif': '.gif',
        'image/webp': '.webp', 'image/svg+xml': '.svg',
        'image/x-icon': '.ico', 'image/vnd.microsoft.icon': '.ico',
    }

    def _file_digest(self, field_file):
        digest = hashlib.sha256()
        try:
            with field_file.open('rb') as fh:
                for chunk in iter(lambda: fh.read(65536), b''):
                    digest.update(chunk)
        except (OSError, ValueError):
            return ''
        return digest.hexdigest()

    def _sync_image(self, tenant, directives, field_name):
        """Pull one branding image from Final Target, only when it has changed."""
        url = (directives.get(f'{field_name}_url') or '').strip()
        sig = (directives.get(f'{field_name}_sig') or '').strip()
        if not url or not re.fullmatch(r'[0-9a-f]{64}', sig):
            return None

        # A directive arrives over the network, so it does not get to choose
        # where this server downloads from. Only the host we already check in
        # to is acceptable.
        target = urllib.parse.urlsplit(url)
        allowed = {
            urllib.parse.urlsplit(getattr(settings, 'FT_CONTROL_URL', '')).hostname,
            getattr(settings, 'FT_CONTROL_HOST', '') or None,
        } - {None, ''}
        if target.scheme not in ('http', 'https') or target.hostname not in allowed:
            self.stderr.write(
                f'Ignoring logo from unexpected host "{target.hostname}". '
                f'Expected one of: {", ".join(sorted(allowed)) or "nothing configured"}.'
            )
            return None

        current = getattr(tenant, field_name, None)
        if current and self._file_digest(current) == sig:
            return None

        req = urllib.request.Request(url, headers={
            'User-Agent': 'FinalTargetFleet/1.0 (logo sync; Django ft_checkin)',
        })
        try:
            with urllib.request.urlopen(req, timeout=30) as response:
                content_type = (response.headers.get('Content-Type') or '').split(';')[0].strip().lower()
                # One byte past the cap, so an oversized file is detected
                # rather than silently truncated.
                data = response.read(self.LOGO_MAX_BYTES + 1)
        except (urllib.error.URLError, urllib.error.HTTPError, OSError) as exc:
            self.stderr.write(f'Could not fetch the {field_name}: {exc}')
            return None

        if len(data) > self.LOGO_MAX_BYTES:
            self.stderr.write(f'The {field_name} is larger than 5MB, refusing it.')
            return None
        if content_type not in self.LOGO_TYPES:
            self.stderr.write(f'The {field_name} is not an image we accept (Content-Type: {content_type or "none"}).')
            return None
        if hashlib.sha256(data).hexdigest() != sig:
            self.stderr.write(f'The {field_name} did not match the digest Final Target sent, discarding it.')
            return None

        stem = os.path.splitext(os.path.basename(urllib.parse.unquote(target.path)))[0]
        stem = re.sub(r'[^A-Za-z0-9._-]', '', stem)[:60] or field_name
        name = f'{stem}{self.LOGO_TYPES[content_type]}'

        getattr(tenant, field_name).save(name, ContentFile(data), save=True)
        return f'{field_name} updated from Final Target ({len(data) // 1024}KB)'

    def _apply(self, tenant, directives):
        from apps.tenants.models import Tenant, TenantModule, ALWAYS_ON_MODULES

        changes = []

        plan = directives.get('plan')
        valid_plans = {key for key, _ in Tenant.PLAN_CHOICES}
        if plan and plan in valid_plans and plan != tenant.plan:
            tenant.plan = plan
            tenant.save(update_fields=['plan'])
            changes.append(f'plan set to {plan}')

        enable = set(directives.get('modules_enabled') or [])
        disable = set(directives.get('modules_disabled') or []) - set(ALWAYS_ON_MODULES)

        for key in sorted(enable):
            row, created = TenantModule.objects.get_or_create(
                tenant=tenant, module=key, defaults={'is_enabled': True}
            )
            if not created and not row.is_enabled:
                row.is_enabled = True
                row.save(update_fields=['is_enabled'])
                changes.append(f'enabled {key}')
            elif created:
                changes.append(f'enabled {key}')

        for key in sorted(disable):
            updated = TenantModule.objects.filter(
                tenant=tenant, module=key, is_enabled=True
            ).update(is_enabled=False)
            if updated:
                changes.append(f'disabled {key}')

        # Panel colours are owned by Final Target, so whatever the master
        # sends wins. Only obvious hex values are accepted, and a blank or
        # malformed value is ignored rather than wiping a working colour.
        colour_fields = {
            'accent_colour':   'accent colour',
            'cms_dark_colour': 'sidebar colour',
            'cms_text_colour': 'sidebar text colour',
        }
        colour_updates = []
        for field, label in colour_fields.items():
            value = (directives.get(field) or '').strip()
            if not re.fullmatch(r'#[0-9A-Fa-f]{6}', value):
                continue
            if getattr(tenant, field, '') != value:
                setattr(tenant, field, value)
                colour_updates.append(field)
                changes.append(f'{label} set to {value}')
        if colour_updates:
            tenant.save(update_fields=colour_updates)

        # Branding belongs to the site, not to Final Target. This used to
        # overwrite the name, logo and favicon on every check-in, so a fresh
        # install wore Final Target's identity until someone noticed. The
        # master can still set them, but only deliberately, by sending
        # force_branding, and otherwise only to fill a blank.
        force = directives.get('force_branding') is True

        site_name = (directives.get('site_name') or '').strip()
        if site_name and site_name != tenant.name and (force or not (tenant.name or '').strip()):
            tenant.name = site_name
            tenant.save(update_fields=['name'])
            changes.append(f'name set to {site_name}')

        for field_name in ('logo', 'favicon'):
            if not force and getattr(tenant, field_name, None):
                continue
            change = self._sync_image(tenant, directives, field_name)
            if change:
                changes.append(change)

        target = directives.get('target_version') or ''
        current = getattr(settings, 'CMS_VERSION', '')
        if target and target != current:
            self.stdout.write(
                f'Final Target expects FT OS {target}, this install is on {current}. '
                f'Upgrade is not applied automatically.'
            )

        if changes:
            self._flush_panel_cache()
            self.stdout.write('Applied: ' + ', '.join(changes))
        else:
            self.stdout.write('Already in sync, nothing changed.')

    def _flush_panel_cache(self):
        try:
            from apps.tenants.middleware import invalidate_panel_host_cache
            invalidate_panel_host_cache()
        except Exception:
            pass
