"""Tedalal Social — self-hosted social publishing dashboard.

Platform APIs need developer application credentials, approvals and page authorizations.
Never store user passwords, third-party tokens or API secrets in source control.
"""
from __future__ import annotations

import json
import os
import secrets
import sqlite3
import time
import mimetypes
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlencode
from zoneinfo import ZoneInfo

import requests
from cryptography.fernet import Fernet
from dotenv import load_dotenv
from fastapi import FastAPI, Form, UploadFile, File, Request, HTTPException
from fastapi.responses import RedirectResponse, FileResponse, HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from PIL import Image, ImageOps
from starlette.middleware.sessions import SessionMiddleware
from starlette.middleware.trustedhost import TrustedHostMiddleware
import hashlib
import hmac

ROOT = Path(__file__).resolve().parent
load_dotenv(ROOT / '.env')
DATA = Path(os.environ.get('DATA_DIR', str(ROOT / 'data'))).resolve()
MEDIA = DATA / 'media'
DB = DATA / 'app.sqlite3'
MEDIA.mkdir(parents=True, exist_ok=True)
ZONE = ZoneInfo('Asia/Dubai')
BASE = os.environ.get('APP_BASE_URL', 'http://localhost:8000').rstrip('/')
SECRET = os.environ.get('SECRET_KEY', '')
ENC_KEY = os.environ.get('FERNET_KEY', '')
ADMIN_PASSWORD = os.environ.get('ADMIN_PASSWORD', '')
ADMIN_PASSWORD_HASH = os.environ.get('ADMIN_PASSWORD_HASH', '')
API_VER = os.environ.get('META_API_VERSION', 'v24.0')
META_APP_ID = os.environ.get('META_APP_ID', '')
META_APP_SECRET = os.environ.get('META_APP_SECRET', '')
TIKTOK_CLIENT_KEY = os.environ.get('TIKTOK_CLIENT_KEY', '')
TIKTOK_CLIENT_SECRET = os.environ.get('TIKTOK_CLIENT_SECRET', '')

if len(SECRET) < 32 or not ENC_KEY or (not ADMIN_PASSWORD_HASH and (len(ADMIN_PASSWORD) < 12 or ADMIN_PASSWORD.upper().startswith('REPLACE'))):
    raise RuntimeError('Configure SECRET_KEY, FERNET_KEY and ADMIN_PASSWORD_HASH (or strong ADMIN_PASSWORD) in .env first. See README.md.')
FERNET = Fernet(ENC_KEY.encode())

app = FastAPI(title='Tedalal Social', docs_url=None, redoc_url=None, openapi_url=None)
app.add_middleware(SessionMiddleware, secret_key=SECRET, same_site='lax', https_only=BASE.startswith('https://'), max_age=86400)
app.add_middleware(TrustedHostMiddleware, allowed_hosts=os.environ.get('ALLOWED_HOSTS', 'localhost,127.0.0.1').split(','))
templates = Jinja2Templates(directory=str(ROOT / 'templates'))
app.mount('/static', StaticFiles(directory=str(ROOT / 'static')), name='static')


def db():
    c = sqlite3.connect(DB, timeout=20)
    c.row_factory = sqlite3.Row
    c.execute('PRAGMA foreign_keys = ON')
    c.execute('PRAGMA busy_timeout = 20000')
    return c


def generate_password_hash(password):
    salt = secrets.token_bytes(16)
    iterations = 600_000
    digest = hashlib.pbkdf2_hmac('sha256',password.encode(),salt,iterations)
    return 'pbkdf2_sha256$'+str(iterations)+'$'+salt.hex()+'$'+digest.hex()


def check_password_hash(saved,password):
    try:
        algorithm,iter_s,salt_s,digest_s = saved.split('$')
        if algorithm!='pbkdf2_sha256':
            return False
        candidate=hashlib.pbkdf2_hmac('sha256',password.encode(),bytes.fromhex(salt_s),int(iter_s))
        return hmac.compare_digest(candidate,bytes.fromhex(digest_s))
    except (ValueError,TypeError):
        return False


def init_db():
    with db() as c:
        c.executescript('''
        CREATE TABLE IF NOT EXISTS admin(id INTEGER PRIMARY KEY, password_hash TEXT NOT NULL);
        CREATE TABLE IF NOT EXISTS accounts (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            platform TEXT NOT NULL,
            external_id TEXT NOT NULL,
            name TEXT NOT NULL,
            token_enc TEXT NOT NULL,
            refresh_enc TEXT,
            expires_at INTEGER,
            created_at INTEGER NOT NULL,
            UNIQUE(platform, external_id)
        );
        CREATE TABLE IF NOT EXISTS posts (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            body TEXT NOT NULL,
            media_filename TEXT,
            media_type TEXT,
            privacy TEXT,
            created_at INTEGER NOT NULL,
            scheduled_at INTEGER NOT NULL
        );
        CREATE TABLE IF NOT EXISTS jobs (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            post_id INTEGER NOT NULL REFERENCES posts(id),
            account_id INTEGER NOT NULL REFERENCES accounts(id),
            status TEXT NOT NULL DEFAULT 'scheduled',
            remote_id TEXT,
            remote_link TEXT,
            last_error TEXT,
            updated_at INTEGER NOT NULL,
            UNIQUE(post_id, account_id)
        );
        CREATE INDEX IF NOT EXISTS idx_job_status ON jobs(status, updated_at);
        ''')
        if not c.execute('SELECT id FROM admin').fetchone():
            c.execute('INSERT INTO admin(password_hash) VALUES(?)', (ADMIN_PASSWORD_HASH or generate_password_hash(ADMIN_PASSWORD),))


init_db()


def encrypt(s: str | None):
    return FERNET.encrypt(s.encode()).decode() if s is not None else None


def decrypt(s: str | None):
    return FERNET.decrypt(s.encode()).decode() if s else None


def csrf(request: Request):
    if not request.session.get('csrf'):
        request.session['csrf'] = secrets.token_urlsafe(24)
    return request.session['csrf']


def require_csrf(request: Request, provided: str):
    if not secrets.compare_digest(csrf(request), provided or ''):
        raise HTTPException(403, 'Invalid security token')


def logged(request: Request):
    return bool(request.session.get('admin'))


def require_login(request: Request):
    if not logged(request):
        raise HTTPException(401, 'Please log in first')


def local_time(ts: int | None):
    return datetime.fromtimestamp(ts, timezone.utc).astimezone(ZONE).strftime('%Y-%m-%d %I:%M %p') if ts else '—'


templates.env.filters['localtime'] = local_time


def page(request, name, **kwargs):
    return templates.TemplateResponse(request=request, name=name, context=dict(csrf=csrf(request), logged=logged(request), **kwargs))


def go(path='/', msg=None):
    if msg:
        from urllib.parse import quote
        path += ('&' if '?' in path else '?') + 'notice=' + quote(msg)
    return RedirectResponse(path, status_code=303)


@app.get('/login', response_class=HTMLResponse)
def login_page(request: Request):
    return page(request, 'login.html', notice=request.query_params.get('notice'))


@app.post('/login')
def login(request: Request, password: str = Form(...), token: str = Form(...)):
    require_csrf(request, token)
    with db() as c:
        pw_hash = c.execute('SELECT password_hash FROM admin WHERE id=1').fetchone()['password_hash']
    if check_password_hash(pw_hash, password):
        request.session.clear()
        request.session['admin'] = True
        csrf(request)
        return go('/')
    return go('/login', 'كلمة المرور غير صحيحة')


@app.post('/logout')
def logout(request: Request, token: str = Form(...)):
    require_csrf(request, token)
    request.session.clear()
    return go('/login')


@app.get('/', response_class=HTMLResponse)
def dashboard(request: Request):
    if not logged(request):
        return go('/login')
    with db() as c:
        accounts = [dict(x) for x in c.execute('SELECT id, platform, name FROM accounts ORDER BY platform,name').fetchall()]
        posts = [dict(x) for x in c.execute('''SELECT p.*, COUNT(j.id) jobs_total,
            SUM(CASE WHEN j.status='published' THEN 1 ELSE 0 END) published_total
            FROM posts p LEFT JOIN jobs j ON j.post_id=p.id
            GROUP BY p.id ORDER BY p.id DESC LIMIT 30''')]
        jobs = [dict(x) for x in c.execute('''SELECT j.id,j.post_id,j.status,j.remote_link,j.last_error,j.updated_at,
            a.name account_name,a.platform FROM jobs j JOIN accounts a ON a.id=j.account_id
            ORDER BY j.id DESC LIMIT 100''')]
    per_post = {}
    for j in jobs:
        per_post.setdefault(j['post_id'], []).append(j)
    return page(request, 'dashboard.html', accounts=accounts, posts=posts, jobs=per_post,
                now_local=datetime.now(ZONE).strftime('%Y-%m-%dT%H:%M'),
                notice=request.query_params.get('notice'),
                meta_configured=bool(META_APP_ID and META_APP_SECRET),
                tiktok_configured=bool(TIKTOK_CLIENT_KEY and TIKTOK_CLIENT_SECRET),
                base_https=BASE.startswith('https://'))


@app.get('/media/{filename}')
def public_media(filename: str):
    # Media URLs must be fetchable over HTTPS by Instagram and TikTok.
    if not filename or '/' in filename or '\\' in filename or '..' in filename:
        raise HTTPException(404)
    path = MEDIA / filename
    if not path.is_file():
        raise HTTPException(404)
    return FileResponse(path, media_type=mimetypes.guess_type(path.name)[0])


def graph(method, path, token, params=None, files=None, timeout=30, video=False):
    host = 'https://graph-video.facebook.com' if video else 'https://graph.facebook.com'
    u = f'{host}/{API_VER}/{path.lstrip("/")}'
    kwargs = dict(timeout=timeout, headers={'Authorization': f'Bearer {token}'})
    if method == 'GET':
        res = requests.get(u, params=params, **kwargs)
    else:
        res = requests.post(u, data=params, files=files, **kwargs)
    try:
        obj = res.json()
    except ValueError:
        raise RuntimeError(f'Meta API HTTP {res.status_code}')
    if not res.ok or 'error' in obj:
        err = obj.get('error', {})
        raise RuntimeError('Meta: ' + str(err.get('message', f'HTTP {res.status_code}'))[:250])
    return obj


def tickapi(token, path, body):
    r = requests.post('https://open.tiktokapis.com' + path, json=body,
                      headers={'Authorization': f'Bearer {token}'}, timeout=40)
    try:
        v = r.json()
    except ValueError:
        raise RuntimeError(f'TikTok API HTTP {r.status_code}')
    error = v.get('error', {})
    if not r.ok or error.get('code', 'ok') != 'ok':
        raise RuntimeError('TikTok: ' + str(error.get('message') or error.get('code') or r.status_code)[:250])
    return v.get('data', {})


def save_account(platform, external_id, name, token, refresh=None, expires=None):
    with db() as c:
        c.execute('''INSERT INTO accounts(platform,external_id,name,token_enc,refresh_enc,expires_at,created_at)
          VALUES(?,?,?,?,?,?,?) ON CONFLICT(platform,external_id) DO UPDATE SET
          name=excluded.name,token_enc=excluded.token_enc,refresh_enc=excluded.refresh_enc,
          expires_at=excluded.expires_at''',
          (platform, str(external_id), name, encrypt(token), encrypt(refresh), expires, int(time.time())))


@app.get('/auth/meta')
def auth_meta(request: Request):
    require_login(request)
    if not META_APP_ID or not META_APP_SECRET:
        return go('/', 'أضف مفاتيح Meta أولًا')
    state = secrets.token_urlsafe(26)
    request.session['meta_state'] = state
    params = dict(client_id=META_APP_ID, redirect_uri=BASE+'/auth/meta/callback', state=state,
                  response_type='code', scope='pages_show_list,pages_read_engagement,pages_manage_posts,instagram_basic,instagram_content_publish')
    return RedirectResponse('https://www.facebook.com/'+API_VER+'/dialog/oauth?'+urlencode(params))


@app.get('/auth/meta/callback')
def meta_callback(request: Request, code: str = '', state: str = '', error_message: str = ''):
    require_login(request)
    known = request.session.pop('meta_state', '')
    if not known or not secrets.compare_digest(known, state):
        raise HTTPException(403, 'OAuth state mismatch')
    if not code:
        return go('/', error_message or 'فشل تفويض Meta')
    try:
        token_obj = requests.get(f'https://graph.facebook.com/{API_VER}/oauth/access_token', params={
            'client_id': META_APP_ID, 'client_secret': META_APP_SECRET,
            'redirect_uri': BASE+'/auth/meta/callback', 'code': code}, timeout=30).json()
        if not token_obj.get('access_token'):
            raise RuntimeError('لم يتم إرجاع رمز Meta')
        long_obj = requests.get(f'https://graph.facebook.com/{API_VER}/oauth/access_token', params={
            'grant_type': 'fb_exchange_token', 'client_id': META_APP_ID,
            'client_secret': META_APP_SECRET, 'fb_exchange_token': token_obj['access_token']}, timeout=30).json()
        token = long_obj.get('access_token', token_obj['access_token'])
        pages = graph('GET', 'me/accounts', token, {'fields':'id,name,access_token', 'limit':100}).get('data', [])
        count = 0
        for p in pages:
            if not p.get('access_token'):
                continue
            save_account('facebook', p['id'], p.get('name','Facebook Page'), p['access_token'])
            count += 1
            try:
                ig = graph('GET', p['id'], p['access_token'], {'fields':'instagram_business_account'})
                igid = (ig.get('instagram_business_account') or {}).get('id')
                if igid:
                    save_account('instagram', igid, 'Instagram · '+p.get('name',''), p['access_token'])
                    count += 1
            except Exception:
                pass  # Page may not have a linked professional Instagram account.
        return go('/', f'تم ربط {count} حساب/صفحة. تحقق من الأذونات قبل النشر.')
    except Exception as e:
        return go('/', 'تعذر ربط Meta: '+str(e)[:160])


@app.get('/auth/tiktok')
def auth_tiktok(request: Request):
    require_login(request)
    if not TIKTOK_CLIENT_KEY or not TIKTOK_CLIENT_SECRET:
        return go('/', 'أضف مفاتيح TikTok أولًا')
    state = secrets.token_urlsafe(28)
    request.session['tiktok_state'] = state
    params = dict(client_key=TIKTOK_CLIENT_KEY, scope='user.info.basic,video.publish',
                  response_type='code', redirect_uri=BASE+'/auth/tiktok/callback', state=state)
    return RedirectResponse('https://www.tiktok.com/v2/auth/authorize/?'+urlencode(params))


@app.get('/auth/tiktok/callback')
def tiktok_callback(request: Request, code: str = '', state: str = '', error: str = ''):
    require_login(request)
    known = request.session.pop('tiktok_state', '')
    if not known or not secrets.compare_digest(known, state):
        raise HTTPException(403, 'OAuth state mismatch')
    if error or not code:
        return go('/', 'TikTok رفض الربط: '+error)
    try:
        r = requests.post('https://open.tiktokapis.com/v2/oauth/token/', data={
            'client_key': TIKTOK_CLIENT_KEY, 'client_secret': TIKTOK_CLIENT_SECRET,
            'code': code, 'grant_type':'authorization_code',
            'redirect_uri':BASE+'/auth/tiktok/callback'}, timeout=30)
        obj = r.json()
        if not r.ok or not obj.get('access_token'):
            raise RuntimeError(str(obj.get('error_description') or obj.get('error','No token'))[:160])
        access = obj['access_token']
        u = requests.get('https://open.tiktokapis.com/v2/user/info/',
                         params={'fields':'open_id,display_name'}, headers={'Authorization':'Bearer '+access}, timeout=25).json()
        user = u.get('data',{}).get('user',{})
        save_account('tiktok', obj['open_id'], user.get('display_name') or 'TikTok', access,
                     obj.get('refresh_token'), int(time.time())+obj.get('expires_in',86400))
        return go('/', 'تم ربط TikTok. النشر العام يتطلب موافقة TikTok على التطبيق.')
    except Exception as e:
        return go('/', 'تعذر ربط TikTok: '+str(e)[:160])


@app.post('/accounts/{account_id}/disconnect')
def disconnect(request: Request, account_id: int, token: str = Form(...)):
    require_login(request)
    require_csrf(request, token)
    with db() as c:
        c.execute('DELETE FROM jobs WHERE account_id=? AND status="scheduled"', (account_id,))
        # Existing jobs remain in history; mark connection as disabled instead of deleting.
        c.execute('UPDATE accounts SET token_enc=?, refresh_enc=NULL WHERE id=?', (encrypt(''),account_id))
    return go('/', 'تم إيقاف اتصال الحساب وإلغاء المهام المستقبلية الخاصة به')


def refresh_tiktok(account):
    tok = decrypt(account['token_enc'])
    if not account['expires_at'] or account['expires_at'] > int(time.time())+600:
        return tok
    refresh = decrypt(account['refresh_enc'])
    if not refresh:
        raise RuntimeError('رمز TikTok منتهي؛ أعد ربط الحساب')
    r = requests.post('https://open.tiktokapis.com/v2/oauth/token/', data={
        'client_key':TIKTOK_CLIENT_KEY, 'client_secret':TIKTOK_CLIENT_SECRET,
        'grant_type':'refresh_token', 'refresh_token':refresh}, timeout=30)
    obj = r.json()
    if not r.ok or not obj.get('access_token'):
        raise RuntimeError('فشل تجديد رمز TikTok؛ أعد ربط الحساب')
    new_tok = obj['access_token']
    with db() as c:
        c.execute('UPDATE accounts SET token_enc=?, refresh_enc=?, expires_at=? WHERE id=?',
                  (encrypt(new_tok),encrypt(obj.get('refresh_token') or refresh),
                   int(time.time())+obj.get('expires_in',86400),account['id']))
    return new_tok


def tiktok_privacy(account):
    tok = refresh_tiktok(account)
    return tickapi(tok, '/v2/post/publish/creator_info/query/', {})


@app.get('/api/tiktok-privacy')
def privacy_options(request: Request):
    require_login(request)
    with db() as c:
        accts = [dict(a) for a in c.execute("SELECT * FROM accounts WHERE platform='tiktok'")]
    result = {}
    for acc in accts:
        try:
            info = tiktok_privacy(acc)
            result[str(acc['id'])] = {'name':acc['name'], 'options':info.get('privacy_level_options',[]),
                                      'disable_comment':info.get('comment_disabled',False)}
        except Exception as e:
            result[str(acc['id'])] = {'name':acc['name'], 'options':[], 'error':str(e)[:140]}
    from fastapi.responses import JSONResponse
    return JSONResponse(result)


@app.post('/posts')
async def add_post(request: Request, token: str = Form(...), body: str = Form(''),
                   scheduled: str = Form(''), account_ids: list[int] = Form([]),
                   privacy: str = Form(''), consent: str = Form(''),
                   media: UploadFile | None = File(None)):
    require_login(request)
    require_csrf(request, token)
    body = body.strip()
    if not body or len(body) > 2200:
        return go('/', 'نص البوست مطلوب، بحد أقصى 2200 حرف')
    if not account_ids:
        return go('/', 'اختر حسابًا واحدًا على الأقل')
    with db() as c:
        placeholders = ','.join('?' for _ in account_ids)
        accounts = [dict(x) for x in c.execute(f'SELECT * FROM accounts WHERE id IN ({placeholders})', account_ids)]
    if len(accounts) != len(set(account_ids)) or any(not decrypt(a['token_enc']) for a in accounts):
        return go('/', 'بعض الحسابات غير متصلة')
    chosen = {a['platform'] for a in accounts}
    if ('instagram' in chosen or 'tiktok' in chosen) and not (media and media.filename):
        return go('/', 'إنستجرام وتيك توك يحتاجان صورة أو فيديو')
    if 'tiktok' in chosen:
        if consent != 'yes':
            return go('/', 'يلزم تأكيد الموافقة على نشر المحتوى إلى تيك توك')
        for a in accounts:
            if a['platform'] == 'tiktok':
                try:
                    options = tiktok_privacy(a).get('privacy_level_options',[])
                except Exception as e:
                    return go('/', 'تعذر قراءة خيارات الخصوصية من TikTok: '+str(e)[:100])
                if privacy not in options:
                    return go('/', 'مستوى الخصوصية غير مسموح لهذا الحساب؛ حدّث خيارات TikTok')
    if scheduled:
        try:
            when = int(datetime.strptime(scheduled,'%Y-%m-%dT%H:%M').replace(tzinfo=ZONE).timestamp())
        except ValueError:
            return go('/', 'تاريخ النشر غير صحيح')
    else:
        when = int(time.time())
    kind = None
    filename = None
    if media and media.filename:
        suffix = Path(media.filename).suffix.lower()
        if suffix not in ('.jpeg','.jpg','.png','.mp4','.mov'):
            return go('/', 'المسموح صور JPG/PNG أو فيديو MP4/MOV')
        content = await media.read(100*1024*1024+1)
        if len(content)>100*1024*1024:
            return go('/', 'أقصى حجم للملف 100 ميجابايت')
        if suffix in ('.jpg','.jpeg','.png'):
            try:
                import io
                im = Image.open(io.BytesIO(content))
                im.verify()
                im = Image.open(io.BytesIO(content))
                im = ImageOps.exif_transpose(im).convert('RGB')
                if im.width > 6000 or im.height > 6000:
                    return go('/', 'أبعاد الصورة كبيرة جدًا')
                filename = secrets.token_hex(20)+'.jpg'
                im.save(MEDIA/filename, 'JPEG', quality=90, optimize=True)
                kind = 'image'
            except Exception:
                return go('/', 'ملف الصورة تالف أو غير مدعوم')
        else:
            filename = secrets.token_hex(20)+suffix
            (MEDIA/filename).write_bytes(content)
            kind = 'video'
    if 'instagram' in chosen and kind not in ('image','video'):
        return go('/', 'إنستجرام يحتاج صورة أو ريلز')
    if 'tiktok' in chosen and kind not in ('image','video'):
        return go('/', 'تيك توك يحتاج صورة أو فيديو')
    if ('instagram' in chosen or ('tiktok' in chosen and kind=='image')) and not BASE.startswith('https://'):
        return go('/', 'يجب ضبط رابط HTTPS عام للموقع قبل النشر على إنستجرام أو صور تيك توك')
    now = int(time.time())
    with db() as c:
        pid = c.execute('INSERT INTO posts(body,media_filename,media_type,privacy,created_at,scheduled_at) VALUES(?,?,?,?,?,?)',
                        (body,filename,kind,privacy if 'tiktok' in chosen else None,now,when)).lastrowid
        c.executemany('INSERT INTO jobs(post_id,account_id,status,updated_at) VALUES(?,?,?,?)',
                      [(pid,a['id'],'scheduled',now) for a in accounts])
    return go('/', 'تم حفظ البوست في قائمة النشر. يقوم عامل النشر بمعالجته عند الموعد.')


@app.post('/jobs/{job_id}/retry')
def retry(request: Request, job_id: int, token: str = Form(...)):
    require_login(request)
    require_csrf(request, token)
    with db() as c:
        row = c.execute('SELECT status FROM jobs WHERE id=?',(job_id,)).fetchone()
        if row and row['status']=='failed':
            c.execute('UPDATE jobs SET status="scheduled", last_error=NULL, updated_at=? WHERE id=?', (int(time.time()),job_id))
            return go('/', 'تم إعادة جدولة المهمة؛ تأكد أولًا أن المنصة لم تنشر نسخة سابقة')
    return go('/', 'إعادة المحاولة متاحة للمهام الفاشلة فقط')


def publish_meta(acct, post):
    token = decrypt(acct['token_enc'])
    pid = acct['external_id']
    typ = post['media_type']
    path = MEDIA/post['media_filename'] if typ else None
    if acct['platform']=='facebook':
        if not typ:
            result = graph('POST',f'{pid}/feed',token,{'message':post['body']})
        elif typ=='image':
            with open(path,'rb') as fp:
                result = graph('POST',f'{pid}/photos',token,{'caption':post['body']},
                               files={'source':(path.name,fp,'image/jpeg')},timeout=80)
        else:
            with open(path,'rb') as fp:
                result = graph('POST',f'{pid}/videos',token,{'description':post['body']},
                               files={'source':(path.name,fp,mimetypes.guess_type(path)[0] or 'video/mp4')},
                               timeout=160,video=True)
        rid = result.get('post_id') or result.get('id')
        return rid, f'https://www.facebook.com/{rid}' if rid else ''
    if not typ:
        raise RuntimeError('إنستجرام لا يقبل بوست نصي بدون صورة أو ريلز')
    url = BASE+'/media/'+post['media_filename']
    if typ=='image':
        payload = {'image_url':url,'caption':post['body']}
    else:
        payload = {'media_type':'REELS','video_url':url,'caption':post['body']}
    container_id = graph('POST',f'{pid}/media',token,payload)['id']
    # Video containers are asynchronous. Wait for READY before media_publish.
    if typ=='video':
        ready = False
        for _ in range(12):
            time.sleep(5)
            status = graph('GET',container_id,token,{'fields':'status_code'})['status_code']
            if status == 'FINISHED':
                ready=True
                break
            if status in ('ERROR','EXPIRED'):
                raise RuntimeError('Meta فشلت في معالجة فيديو إنستجرام')
        if not ready:
            raise RuntimeError('فيديو إنستجرام قيد المعالجة؛ راجع الحالة قبل إعادة المحاولة')
    result = graph('POST',f'{pid}/media_publish',token,{'creation_id':container_id})
    rid = result['id']
    return rid, ''  # Instagram media ID is not a public shortcode.


def publish_tiktok(acct, post):
    tok = refresh_tiktok(acct)
    creator = tickapi(tok, '/v2/post/publish/creator_info/query/', {})
    if post['privacy'] not in creator.get('privacy_level_options', []):
        raise RuntimeError('خيار الخصوصية لم يعد متاحًا؛ أنشئ بوستًا جديدًا بالاختيار الصحيح')
    info = {'privacy_level':post['privacy'], 'disable_comment':bool(creator.get('comment_disabled',False)),
            'brand_organic_toggle':True}
    path = MEDIA/post['media_filename']
    if post['media_type']=='image':
        if not BASE.startswith('https://'):
            raise RuntimeError('صور TikTok تحتاج رابط HTTPS من نطاق موثق لدى TikTok')
        req = {'post_mode':'DIRECT_POST','media_type':'PHOTO',
               'post_info':{'title':post['body'][:80],'description':post['body'],
                            'privacy_level':post['privacy'], 'disable_comment':info['disable_comment'],
                            'brand_organic_toggle':True},
               'source_info':{'source':'PULL_FROM_URL','photo_images':[BASE+'/media/'+path.name],
                              'photo_cover_index':0}}
        out = tickapi(tok,'/v2/post/publish/content/init/',req)
    else:
        size = path.stat().st_size
        chunk = min(10*1024*1024, size)
        # For uploads >= 5MB use 10MB chunks; any remainder is allowed for the final chunk.
        n = (size+chunk-1)//chunk
        req = {'post_info':{'title':post['body'], 'privacy_level':post['privacy'],
                            'disable_duet':bool(creator.get('duet_disabled',False)),
                            'disable_stitch':bool(creator.get('stitch_disabled',False)),
                            'disable_comment':info['disable_comment'],
                            'brand_organic_toggle':True},
               'source_info':{'source':'FILE_UPLOAD','video_size':size,
                              'chunk_size':chunk,'total_chunk_count':n}}
        out = tickapi(tok,'/v2/post/publish/video/init/',req)
        upload_url = out['upload_url']
        upload_type = 'video/quicktime' if path.suffix=='.mov' else 'video/mp4'
        if not upload_url.startswith('https://open-upload.tiktokapis.com/'):
            raise RuntimeError('TikTok returned unexpected upload host')
        with open(path,'rb') as fp:
            for i in range(n):
                block = fp.read(chunk)
                start = i*chunk
                end = start+len(block)-1
                r = requests.put(upload_url, data=block, headers={
                    'Content-Type':upload_type, 'Content-Length':str(len(block)),
                    'Content-Range':f'bytes {start}-{end}/{size}'}, timeout=180)
                if not r.ok:
                    raise RuntimeError('TikTok video upload failed (HTTP '+str(r.status_code)+')')
    return out['publish_id'], ''


def update_job(jid, status, remote_id=None, remote_link=None, err=None):
    with db() as c:
        c.execute('''UPDATE jobs SET status=?,remote_id=COALESCE(?,remote_id),
            remote_link=COALESCE(?,remote_link),last_error=?,updated_at=? WHERE id=?''',
                  (status,remote_id,remote_link,err,int(time.time()),jid))


def process_due(limit=10):
    """Call from ONE worker process. Never automatically retry uncertain submissions."""
    for _ in range(limit):
        with db() as c:
            c.execute('BEGIN IMMEDIATE')
            item = c.execute('''SELECT j.id FROM jobs j JOIN posts p ON j.post_id=p.id
                WHERE j.status='scheduled' AND p.scheduled_at <= ? ORDER BY p.scheduled_at,j.id LIMIT 1''',
                (int(time.time()),)).fetchone()
            if not item:
                c.commit()
                break
            jid = item['id']
            c.execute('UPDATE jobs SET status="processing",updated_at=? WHERE id=?', (int(time.time()),jid))
            c.commit()
        with db() as c:
            job = c.execute('''SELECT j.id AS job_id, a.*,p.body,p.media_filename,p.media_type,p.privacy
            FROM jobs j JOIN accounts a ON a.id=j.account_id JOIN posts p ON p.id=j.post_id
            WHERE j.id=?''',(jid,)).fetchone()
        if not job:
            continue
        try:
            if not decrypt(job['token_enc']):
                raise RuntimeError('الحساب غير متصل')
            if job['platform']=='tiktok':
                rid,url = publish_tiktok(job, job)
                update_job(jid,'submitted',rid,url)
            else:
                rid,url = publish_meta(job, job)
                update_job(jid,'published',rid,url)
        except Exception as e:
            update_job(jid,'failed',err=str(e)[:400])


def poll_tiktok(limit=30):
    with db() as c:
        jobs = [dict(x) for x in c.execute('''SELECT j.id AS job_id,j.remote_id,a.* FROM jobs j
           JOIN accounts a ON a.id=j.account_id
           WHERE j.status='submitted' AND a.platform='tiktok' ORDER BY j.updated_at LIMIT ?''', (limit,))]
    for job in jobs:
        try:
            token = refresh_tiktok(job)
            obj = tickapi(token,'/v2/post/publish/status/fetch/',{'publish_id':job['remote_id']})
            state = obj.get('status')
            if state == 'PUBLISH_COMPLETE':
                ids = obj.get('publicaly_available_post_id') or []
                update_job(job['job_id'], 'published')
            elif state == 'FAILED':
                update_job(job['job_id'], 'failed',err='TikTok: '+str(obj.get('fail_reason','غير معروف')))
        except Exception:
            pass  # Recheck on next worker pass, don't claim success.


@app.get('/health')
def health():
    return {'ok': True, 'app': 'Tedalal Social'}
