# Author: Lộc Vũ Trung
# VPS PocketBase REST client for LVT4U QGIS plugin
# Pure Python — no pip dependencies (urllib only)

"""
Synchronises QGIS project data with the LVTCenter VPS via PocketBase REST API.
Authentication uses username + password with JWT tokens.
"""

import json
import mimetypes
import os
import ssl
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime
from pathlib import Path

from . import endpoint_resolver


# ──────────────────────────────────────────────────────
# Constants
# ──────────────────────────────────────────────────────

_TOKEN_FILE = Path.home() / '.lvtfield' / 'vps_tokens.json'
_INITIAL_PASS = '12345678#a'  # nosec B105 - default placeholder for new accounts only
_SSL_CTX = ssl.create_default_context()

# Collections that hold project data
_PROJECT_COLLECTIONS = ['projects', 'layers', 'features']

# Collections for field trips
_TRIP_COLLECTIONS = [
    'field_trips', 'trip_members', 'trip_diary',
    'meal_checks', 'trip_expenses', 'trip_fund_transactions', 'trip_guests',
]

# Collections for forest surveys
_FOREST_COLLECTIONS = [
    'forest_survey_projects', 'sample_plots', 'plot_trees', 'plot_bamboos',
]

# Collections for app auxiliary data (synced by Flutter, now also by QGIS)
_APP_AUXILIARY_COLLECTIONS = [
    'print_layouts', 'form_fields', 'gps_tracks',
    'trees', 'plots', 'surveys', 'species',
]

_ALL_COLLECTIONS = (
    _PROJECT_COLLECTIONS + _TRIP_COLLECTIONS
    + _FOREST_COLLECTIONS + _APP_AUXILIARY_COLLECTIONS  # noqa: W503
)


# ──────────────────────────────────────────────────────
# VpsClient
# ──────────────────────────────────────────────────────

class VpsClient:
    """PocketBase REST client for VPS sync operations."""

    def __init__(self):
        self._token = None
        self._user = None  # dict: id, username, name, email
        self._base_url = None

    # ── Properties ─────────────────────────────────────

    @property
    def base_url(self):
        if self._base_url is None:
            self._base_url = endpoint_resolver.resolve()
        return self._base_url

    @property
    def is_logged_in(self):
        return self._token is not None and len(self._token) > 0

    @property
    def current_user(self):
        return self._user or {}

    @property
    def display_name(self):
        if self._user:
            return self._user.get('username') or self._user.get('name') or 'User'
        return ''

    # ── HTTP helpers ───────────────────────────────────

    def _request(self, method, path, data=None, params=None):
        """Send HTTP request to PocketBase API."""
        url = self.base_url + path
        if params:
            url += '?' + urllib.parse.urlencode(params)

        body = json.dumps(data).encode('utf-8') if data else None
        headers = {'Content-Type': 'application/json'}
        if self._token:
            headers['Authorization'] = self._token

        req = urllib.request.Request(url, data=body, headers=headers, method=method)
        try:
            with urllib.request.urlopen(req, timeout=30, context=_SSL_CTX) as resp:  # nosec B310
                return resp.status, json.loads(resp.read())
        except urllib.error.HTTPError as e:
            try:
                return e.code, json.loads(e.read())
            except Exception:
                return e.code, {'message': str(e)}
        except Exception as e:
            return 0, {'message': str(e)}

    def _get(self, path, params=None):
        return self._request('GET', path, params=params)

    def _post(self, path, data):
        return self._request('POST', path, data)

    def _patch(self, path, data):
        return self._request('PATCH', path, data)

    def _delete(self, path):
        return self._request('DELETE', path)

    def _upsert(self, collection, record_id, data):
        """Try PATCH (update), if 404 then POST (create)."""
        if record_id:
            status, body = self._patch(
                f'/api/collections/{collection}/records/{record_id}', data
            )
            if status in (200, 204):
                return status, body
            # If 404, fall through to create
            if status != 404:
                return status, body

        # Create new
        return self._post(f'/api/collections/{collection}/records', data)

    def delete_project(self, project_id):
        """Delete a project by ID."""
        status, _ = self._delete(f'/api/collections/projects/records/{project_id}')
        return status in (200, 204)

    def _get_all(self, collection, filter_str=None, per_page=500):
        """Get all records from a collection, handling pagination."""
        records = []
        page = 1
        while True:
            params = {'perPage': per_page, 'page': page}
            if filter_str:
                params['filter'] = filter_str
            status, body = self._get(
                f'/api/collections/{collection}/records', params
            )
            if status != 200:
                break
            items = body.get('items', [])
            records.extend(items)
            if len(items) < per_page:
                break
            page += 1
        return records

    # ── Session persistence ────────────────────────────

    def _save_session(self):
        """Save token + user info to disk."""
        _TOKEN_FILE.parent.mkdir(parents=True, exist_ok=True)
        data = {'token': self._token, 'user': self._user}
        _TOKEN_FILE.write_text(json.dumps(data, indent=2), encoding='utf-8')

    def auth_refresh(self):
        """Refresh token to keep session alive."""
        if not self.is_logged_in:
            return False
        status, body = self._post('/api/collections/users/auth-refresh')
        if status == 200:
            self._token = body.get('token')
            record = body.get('record', {})
            self._user = {
                'id': record.get('id', ''),
                'username': record.get('username', ''),
                'name': record.get('name', ''),
                'email': record.get('email', ''),
            }
            self._save_session()
            return True
        return False

    def restore_session(self):
        """Load saved token from disk."""
        if _TOKEN_FILE.exists():
            try:
                data = json.loads(_TOKEN_FILE.read_text(encoding='utf-8'))
                self._token = data.get('token')
                self._user = data.get('user')
                if self.is_logged_in:
                    # Silently refresh to keep alive, but ignore errors if offline
                    self.auth_refresh()
                    return True
            except Exception:  # nosec B110
                pass  # nosec B110
        return False

    def _clear_session(self):
        """Remove saved token file."""
        self._token = None
        self._user = None
        if _TOKEN_FILE.exists():
            _TOKEN_FILE.unlink()

    # ── Authentication ─────────────────────────────────

    def register(self, username, name, organization=None):
        """Register a new user with default password. Auto-login on success."""
        data = {
            'username': username,
            'email': f'{username}@lvt.local',
            'password': _INITIAL_PASS,
            'passwordConfirm': _INITIAL_PASS,
            'name': name,
        }
        if organization:
            data['organization'] = organization

        status, body = self._post('/api/collections/users/records', data)
        if status in (200, 201):
            # Auto-login
            return self.login(username, _INITIAL_PASS)
        return False, body.get('message', 'Registration failed')

    def login(self, identity, password):
        """Login with username or email. Returns (success, message)."""
        status, body = self._post('/api/collections/users/auth-with-password', {
            'identity': identity,
            'password': password,
        })
        if status == 200:
            self._token = body.get('token')
            record = body.get('record', {})
            self._user = {
                'id': record.get('id', ''),
                'username': record.get('username', ''),
                'name': record.get('name', ''),
                'email': record.get('email', ''),
            }
            self._save_session()
            return True, 'Login successful'
        return False, body.get('message', 'Login failed')

    def logout(self):
        """Clear authentication state."""
        self._clear_session()

    def change_password(self, old_password, new_password):
        """Change password for logged-in user. Returns (success, message)."""
        if not self.is_logged_in or not self._user:
            return False, 'Not logged in'
        user_id = self._user.get('id', '')
        if not user_id:
            return False, 'User ID unknown'
        data = {
            'oldPassword': old_password,
            'password': new_password,
            'passwordConfirm': new_password,
        }
        status, body = self._patch(
            f'/api/collections/users/records/{user_id}', data
        )
        if status in (200, 204):
            # Re-login with new password to refresh token
            username = self._user.get('username', '')
            self.login(username, new_password)
            return True, 'Password changed'
        return False, body.get('message', 'Change password failed')

    def reset_password(self, username):
        """Reset user password to default (admin-like operation).

        Since emails are @lvt.local and cannot receive mail,
        this tries to auth with default password. If that fails,
        uses request-password-reset endpoint.
        Returns (success, message).
        """
        # Try login with default password — maybe never changed
        ok, msg = self.login(username, _INITIAL_PASS)
        if ok:
            return True, 'Password is already the default'
        # Cannot reset without admin access; guide user
        return False, (
            'Không thể tự đặt lại mật khẩu.\n'
            'Vui lòng liên hệ quản trị viên hoặc\n'
            'tạo tài khoản mới.'
        )

    # ── Health check ───────────────────────────────────

    def check_health(self):
        """Check if server is reachable. Returns (healthy, message)."""
        status, body = self._get('/api/health')
        if status == 200:
            return True, 'Server is healthy'
        return False, body.get('message', 'Server unavailable')

    def get_remote_stats(self):
        """Get record counts for all collections. Returns dict."""
        stats = {}
        for col in _ALL_COLLECTIONS:
            status, body = self._get(
                f'/api/collections/{col}/records', {'perPage': 1}
            )
            if status == 200:
                stats[col] = body.get('totalItems', 0)
            else:
                stats[col] = -1  # error
        return stats

    # ── Push (upload to VPS) ───────────────────────────

    def push_records(self, collection, records):
        """Push a list of records to a collection. Returns (pushed, errors)."""
        pushed, errors = 0, 0
        user_id = self.current_user.get('id', '')

        for rec in records:
            rec_data = dict(rec)
            rec_data['user'] = user_id
            rec_id = rec_data.get('id', None)  # Get without popping so it is sent in POST

            status, _ = self._upsert(collection, rec_id, rec_data)
            if status in (200, 201, 204):
                pushed += 1
            else:
                errors += 1

        return pushed, errors

    def push_projects(self, projects, layers=None, features=None):
        """Push projects + layers + features. Returns SyncResult dict."""
        result = {'pushed': 0, 'errors': 0, 'log': []}

        p, e = self.push_records('projects', projects)
        result['pushed'] += p
        result['errors'] += e
        result['log'].append(f'Projects: {p} pushed, {e} errors')

        if layers:
            p, e = self.push_records('layers', layers)
            result['pushed'] += p
            result['errors'] += e
            result['log'].append(f'Layers: {p} pushed, {e} errors')

        if features:
            p, e = self.push_records('features', features)
            result['pushed'] += p
            result['errors'] += e
            result['log'].append(f'Features: {p} pushed, {e} errors')

        return result

    def push_field_trips(self, trips_data):
        """Push field trips + child tables. trips_data is dict of lists."""
        result = {'pushed': 0, 'errors': 0, 'log': []}
        for col in _TRIP_COLLECTIONS:
            records = trips_data.get(col, [])
            if records:
                p, e = self.push_records(col, records)
                result['pushed'] += p
                result['errors'] += e
                result['log'].append(f'{col}: {p} pushed, {e} errors')
        return result

    def push_forest_surveys(self, surveys_data):
        """Push forest surveys + child tables."""
        result = {'pushed': 0, 'errors': 0, 'log': []}
        for col in _FOREST_COLLECTIONS:
            records = surveys_data.get(col, [])
            if records:
                p, e = self.push_records(col, records)
                result['pushed'] += p
                result['errors'] += e
                result['log'].append(f'{col}: {p} pushed, {e} errors')
        return result

    def push_app_auxiliary(self, aux_data):
        """Push app auxiliary data (print_layouts, form_fields, gps_tracks, trees, plots, surveys, species)."""
        result = {'pushed': 0, 'errors': 0, 'log': []}
        for col in _APP_AUXILIARY_COLLECTIONS:
            records = aux_data.get(col, [])
            if records:
                p, e = self.push_records(col, records)
                result['pushed'] += p
                result['errors'] += e
                result['log'].append(f'{col}: {p} pushed, {e} errors')
        return result

    def push_all(self, data):
        """Push all data (projects + trips + forests + auxiliary). data is dict of lists."""
        result = {'pushed': 0, 'errors': 0, 'log': []}
        for col in _ALL_COLLECTIONS:
            records = data.get(col, [])
            if records:
                p, e = self.push_records(col, records)
                result['pushed'] += p
                result['errors'] += e
                result['log'].append(f'{col}: {p} pushed, {e} errors')
        return result

    # ── Pull (download from VPS) ───────────────────────

    def pull_projects(self):
        """Pull all projects + layers + features from VPS."""
        result = {}
        for col in _PROJECT_COLLECTIONS:
            result[col] = self._get_all(col)
        return result

    def pull_field_trips(self):
        """Pull all field trips + child tables."""
        result = {}
        for col in _TRIP_COLLECTIONS:
            result[col] = self._get_all(col)
        return result

    def pull_forest_surveys(self):
        """Pull all forest surveys + child tables."""
        result = {}
        for col in _FOREST_COLLECTIONS:
            result[col] = self._get_all(col)
        return result

    def pull_app_auxiliary(self):
        """Pull app auxiliary data (print_layouts, form_fields, gps_tracks, trees, plots, surveys, species)."""
        result = {}
        for col in _APP_AUXILIARY_COLLECTIONS:
            result[col] = self._get_all(col)
        return result

    def pull_all(self):
        """Pull everything from VPS. Returns dict of lists."""
        result = {}
        for col in _ALL_COLLECTIONS:
            result[col] = self._get_all(col)
        return result

    def pull_layer_list(self):
        """Fetch projects and layers for selection UI (no features).

        Returns dict with 'projects' and 'layers' lists.
        Each layer includes a 'feature_count' field.
        """
        projects = self._get_all('projects')
        layers = self._get_all('layers')

        # Add feature count for each layer
        for lyr in layers:
            lid = lyr.get('id', '')
            status, body = self._get(
                '/api/collections/features/records',
                {'perPage': 1, 'filter': f'layer_id="{lid}"'}
            )
            lyr['feature_count'] = body.get('totalItems', 0) if status == 200 else 0

        return {'projects': projects, 'layers': layers}

    def pull_selected_layers(self, layer_ids):
        """Pull data only for selected layers.

        Args:
            layer_ids: list of layer ID strings to pull

        Returns:
            dict with 'projects', 'layers', 'features' keys
        """
        # Get selected layers
        layers = []
        for lid in layer_ids:
            status, body = self._get(f'/api/collections/layers/records/{lid}')
            if status == 200:
                layers.append(body)

        # Get parent projects (unique)
        project_ids = list(set(layer_item.get('project_id', '') for layer_item in layers))
        projects = []
        for pid in project_ids:
            if pid:
                status, body = self._get(f'/api/collections/projects/records/{pid}')
                if status == 200:
                    projects.append(body)

        # Get features for selected layers only
        features = []
        for lid in layer_ids:
            records = self._get_all('features', filter_str=f'layer_id="{lid}"')
            features.extend(records)

        return {'projects': projects, 'layers': layers, 'features': features}

    # ── Media upload ───────────────────────────────────

    def push_media(self, feature_id, file_path, caption=''):
        """Upload a media file for a feature.

        Uses multipart/form-data to upload to PocketBase file field.
        Returns (success, record_or_message).
        """
        import uuid

        if not os.path.exists(file_path):
            return False, 'File not found'

        media_type = 'photo' if file_path.lower().endswith(('.jpg', '.jpeg', '.png')) else 'file'
        mime_type = mimetypes.guess_type(file_path)[0] or 'application/octet-stream'
        filename = os.path.basename(file_path)

        # Build multipart form data
        boundary = f'----LVTField{uuid.uuid4().hex[:12]}'
        body_parts = []

        # Text fields
        fields = {
            'id': f'{feature_id}_{filename}',
            'feature_id': feature_id,
            'media_type': media_type,
            'caption': caption,
            'captured_at': datetime.now().isoformat(),
        }
        user_id = self.current_user.get('id', '')
        if user_id:
            fields['user'] = user_id

        for key, val in fields.items():
            body_parts.append(
                f'--{boundary}\r\n'
                f'Content-Disposition: form-data; name="{key}"\r\n\r\n'
                f'{val}\r\n'
            )

        # File field
        with open(file_path, 'rb') as f:
            file_data = f.read()

        file_header = (
            f'--{boundary}\r\n'
            f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'
            f'Content-Type: {mime_type}\r\n\r\n'
        )

        # Assemble body
        body = b''
        for part in body_parts:
            body += part.encode('utf-8')
        body += file_header.encode('utf-8')
        body += file_data
        body += f'\r\n--{boundary}--\r\n'.encode('utf-8')

        # Send request
        url = self.base_url + '/api/collections/media/records'
        headers = {
            'Content-Type': f'multipart/form-data; boundary={boundary}',
        }
        if self._token:
            headers['Authorization'] = self._token

        req = urllib.request.Request(url, data=body, headers=headers, method='POST')
        try:
            with urllib.request.urlopen(req, timeout=60, context=_SSL_CTX) as resp:  # nosec B310
                return True, json.loads(resp.read())
        except urllib.error.HTTPError as e:
            try:
                return False, json.loads(e.read()).get('message', str(e))
            except Exception:
                return False, str(e)
        except Exception as e:
            return False, str(e)

    def pull_media_list(self, feature_ids=None):
        """Fetch media records, optionally filtered by feature IDs.

        Returns list of media records with download URLs.
        """
        if feature_ids:
            # Build OR filter for multiple feature IDs
            filters = ' || '.join(f'feature_id="{fid}"' for fid in feature_ids)
            records = self._get_all('media', filter_str=filters)
        else:
            records = self._get_all('media')

        # Add download URL for each record
        for rec in records:
            rec_id = rec.get('id', '')
            col_id = rec.get('collectionId', rec.get('collectionName', 'media'))
            filename = rec.get('file', '')
            if filename:
                rec['download_url'] = (
                    f'{self.base_url}/api/files/{col_id}/{rec_id}/{filename}'
                )
        return records

    def pull_media_file(self, download_url, save_dir, filename=None):
        """Download a media file from VPS.

        Args:
            download_url: full URL to the file
            save_dir: local directory to save the file
            filename: optional filename override

        Returns (success, local_filepath_or_error).
        """
        if not filename:
            filename = download_url.rsplit('/', 1)[-1]

        os.makedirs(save_dir, exist_ok=True)
        local_path = os.path.join(save_dir, filename)

        headers = {}
        if self._token:
            headers['Authorization'] = self._token

        req = urllib.request.Request(download_url, headers=headers)
        try:
            with urllib.request.urlopen(req, timeout=60, context=_SSL_CTX) as resp:  # nosec B310
                with open(local_path, 'wb') as f:
                    while True:
                        chunk = resp.read(8192)
                        if not chunk:
                            break
                        f.write(chunk)
            return True, local_path
        except Exception as e:
            return False, str(e)

# ──────────────────────────────────────────────────────
# Singleton instance
# ──────────────────────────────────────────────────────


_instance = None


def get_client():
    """Get or create the singleton VpsClient instance."""
    global _instance
    if _instance is None:
        _instance = VpsClient()
        _instance.restore_session()
    return _instance
