import os
import io
import re
import json
import uuid
import secrets
import socket
import threading
import time
from datetime import timedelta
from flask import Flask, request, jsonify, send_from_directory, make_response, session
from openai import OpenAI, APIError as OpenAIAPIError, AuthenticationError as OpenAIAuthError
import urllib.request
import urllib.parse
import urllib.error
import pypdf
import docx
import generators

LOCK_FILE = os.path.join(os.path.dirname(__file__), '.ptilock')
PORT_FILE = os.path.join(os.path.dirname(__file__), '.port')
_server_port = 0

app = Flask(__name__, static_folder='static')
app.secret_key = os.environ.get('SECRET_KEY') or secrets.token_hex(32)
app.config['SESSION_COOKIE_HTTPONLY'] = True
app.config['SESSION_COOKIE_SAMESITE'] = 'Strict'
app.config['SESSION_COOKIE_SECURE'] = False
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(hours=12)

APP_PASSWORD = os.environ.get('APP_PASSWORD', '').strip()
BASIC_SG_PATH = os.path.join(os.path.dirname(__file__), 'basic_style_guide.txt')
# .settings.json stores user preferences including API key in plaintext
SETTINGS_PATH = os.path.join(os.path.dirname(__file__), '.settings.json')
_SETTINGS_DEFAULTS = {
    'api_key': '', 'base_url': '', 'model': '', 'remember': False,
    'theme': 'dark', 'langs': {'source': '', 'target': ''},
    'chunksize': 20, 'termbase': None, 'styleguide': None,
    'license_key': '', 'instance_id': '', 'ls_instance_id': ''
}
ACTIVATE_URL = 'https://imtranslaid.com/api/activate'

BASIC_STYLE_GUIDE = """BASIC STYLE GUIDE — UNIVERSAL DEFAULTS
(Override or expand these with your own style guide)

1. Do not translate proper nouns, brand names, or product names. Preserve them exactly as they appear in the source.
2. Preserve all formatting, punctuation structure, and spacing as closely as the target language allows.
3. Match the register of the source text (formal stays formal, informal stays informal).
4. Do not add, remove, or paraphrase content — translate meaning for meaning, not word for word.
5. Preserve all numbers, dates, currencies, and units of measurement exactly as they appear in the source.
6. Always follow the provided termbase exactly. Do not use synonyms or alternative translations for any term that appears in the termbase.
"""


def _load_active_basic_sg():
    if os.path.isfile(BASIC_SG_PATH):
        with open(BASIC_SG_PATH, encoding='utf-8') as f:
            return f.read().strip()
    return BASIC_STYLE_GUIDE


active_basic_sg = _load_active_basic_sg()

_session_lock = {'token': None, 'last_heartbeat': 0}
_SESSION_TIMEOUT = 90


@app.before_request
def check_request():
    # DNS rebinding protection — reject requests with unexpected Host headers
    if _server_port == 0:
        print('[WARN] _server_port is 0 — skipping Host header check')
    else:
        host = request.host.lower()
        allowed = {f'localhost:{_server_port}', f'127.0.0.1:{_server_port}'}
        if host not in allowed:
            return 'Bad request.', 400

    if not APP_PASSWORD:
        return
    public = {'index', 'ping', 'login', 'static', 'shutdown',
              'claim_session', 'release_session', 'heartbeat'}
    if request.endpoint in public:
        return
    if not session.get('authenticated'):
        return jsonify({'error': 'Unauthorized. Please enter the app password.'}), 401


@app.route('/ping')
def ping():
    return jsonify({
        'auth_required': bool(APP_PASSWORD),
        'authenticated': session.get('authenticated', False),
        'app_dir': os.path.dirname(os.path.abspath(__file__)),
        'port': _server_port,
    })


@app.route('/api/settings', methods=['GET'])
def get_settings():
    try:
        with open(SETTINGS_PATH, 'r', encoding='utf-8') as f:
            data = json.load(f)
    except (FileNotFoundError, json.JSONDecodeError):
        data = {}
    result = dict(_SETTINGS_DEFAULTS)
    result.update(data)
    return jsonify(result)


@app.route('/api/settings', methods=['POST'])
def save_settings():
    # .settings.json contains the API key in plaintext in the app directory
    incoming = request.json or {}
    try:
        with open(SETTINGS_PATH, 'r', encoding='utf-8') as f:
            current = json.load(f)
    except (FileNotFoundError, json.JSONDecodeError):
        current = {}
    current.update(incoming)
    if not current.get('remember', False):
        current.pop('api_key', None)
        current.pop('base_url', None)
        current.pop('model', None)
    # licence fields are always persisted regardless of remember flag
    with open(SETTINGS_PATH, 'w', encoding='utf-8') as f:
        json.dump(current, f, indent=2, ensure_ascii=False)
    return jsonify({'ok': True})


@app.route('/login', methods=['POST'])
def login():
    data = request.json or {}
    if data.get('password') == APP_PASSWORD:
        session.permanent = True
        session['authenticated'] = True
        return jsonify({'ok': True})
    return jsonify({'error': 'Wrong password.'}), 401


@app.route('/logout', methods=['POST'])
def logout():
    session.clear()
    return jsonify({'ok': True})


@app.route('/claim-session', methods=['POST'])
def claim_session():
    force = request.args.get('force') == 'true'
    now = time.time()
    lock = _session_lock
    lock_alive = lock['token'] and (now - lock['last_heartbeat']) < _SESSION_TIMEOUT
    incoming_token = (request.json or {}).get('token', '')

    if lock_alive and lock['token'] == incoming_token:
        lock['last_heartbeat'] = now
        return jsonify({'ok': True, 'token': lock['token']})

    if lock_alive and not force:
        remaining = int(_SESSION_TIMEOUT - (now - lock['last_heartbeat']))
        return jsonify({'error': 'already_active', 'expires_in': max(remaining, 1)}), 409

    new_token = secrets.token_hex(16)
    lock['token'] = new_token
    lock['last_heartbeat'] = now
    return jsonify({'ok': True, 'token': new_token})


@app.route('/release-session', methods=['POST'])
def release_session():
    data = request.get_data(as_text=True)
    try:
        token = json.loads(data).get('token', '') if data else ''
    except (json.JSONDecodeError, AttributeError):
        token = ''
    if _session_lock['token'] == token:
        _session_lock['token'] = None
        _session_lock['last_heartbeat'] = 0
    return jsonify({'ok': True})


@app.route('/heartbeat', methods=['POST'])
def heartbeat():
    data = request.json or {}
    token = data.get('token', '')
    if _session_lock['token'] != token:
        return jsonify({'error': 'session_revoked'}), 409
    _session_lock['last_heartbeat'] = time.time()
    return jsonify({'ok': True})


@app.route('/shutdown', methods=['POST'])
def shutdown():
    session.clear()
    def _kill():
        threading.Event().wait(0.5)
        for f in (LOCK_FILE, PORT_FILE):
            try:
                if os.path.exists(f):
                    os.remove(f)
            except Exception:
                pass
        os._exit(0)
    threading.Thread(target=_kill, daemon=True).start()
    return jsonify({'ok': True})


MAX_SG_BYTES = 5 * 1024 * 1024  # 5 MB

SG_EXTRACTION_PROMPT = """You are an expert at analysing translation style guides.
Extract every rule and guideline from the provided document into clear, structured categories.

Use these five categories as section headings:

1. TERMINOLOGY BINDINGS
   List each as: source term → target term
   Include any forbidden synonyms if mentioned.

2. TONE & REGISTER
   Formality level, grammatical person, voice, register requirements.

3. FORMATTING CONVENTIONS
   Punctuation, numbers, dates, measurements, capitalisation.

4. BRAND & STYLE RULES
   Brand-specific requirements, forbidden words/phrases, stylistic preferences.

5. OTHER RULES
   Anything that does not fit the above.

Be specific and actionable. Every rule must be a clear instruction a translator can follow without ambiguity.
If a section has no applicable rules, write "None found."
Do not add any preamble or closing remarks outside the five sections.
IMPORTANT: Omit any rules about human escalation, flagging uncertainty to project managers, editors,
proofreaders, or team leads, and any workflow steps that require contacting another person."""


def extract_text_from_upload(file):
    name = file.filename.lower()
    data = file.read()
    if len(data) > MAX_SG_BYTES:
        raise ValueError('File exceeds 5 MB limit.')
    if name.endswith('.pdf'):
        reader = pypdf.PdfReader(io.BytesIO(data))
        return '\n'.join(p.extract_text() or '' for p in reader.pages)
    elif name.endswith('.docx'):
        doc = docx.Document(io.BytesIO(data))
        return '\n'.join(p.text for p in doc.paragraphs)
    elif name.endswith('.txt'):
        return data.decode('utf-8', errors='replace')
    elif name.endswith('.rtf'):
        text = data.decode('utf-8', errors='replace')
        text = re.sub(r'\{[^{}]*\}', '', text)
        text = re.sub(r'\\[a-z]+\-?[0-9]*', '', text, flags=re.IGNORECASE)
        text = re.sub(r'[{}\\]', '', text)
        return text.strip()
    elif name.endswith('.doc'):
        raise ValueError(
            '.doc files are not supported. Please open the file in Word, '
            'save it as .docx (File → Save As → Word Document), and try again.'
        )
    else:
        raise ValueError('Unsupported file type. Please use PDF, DOCX, TXT, or RTF.')


@app.route('/extract-sg', methods=['POST'])
def extract_sg():
    file = request.files.get('style_guide')
    if not file or not file.filename:
        return jsonify({'error': 'No file uploaded.'}), 400

    api_key = request.form.get('api_key', '').strip()
    base_url = request.form.get('base_url', '').strip()
    model = request.form.get('model', '').strip()
    domain = request.form.get('domain', '').strip()

    if not api_key:
        return jsonify({'error': 'API key is required. Open Settings (⚙) and enter your key.'}), 400
    if not model:
        return jsonify({'error': 'Model name is required. Open Settings (⚙) and enter a model.'}), 400

    try:
        text = extract_text_from_upload(file)
    except ValueError as e:
        return jsonify({'error': str(e)}), 400

    if not text.strip():
        return jsonify({'error': 'Could not extract any text from the file.'}), 400

    user_message = (
        f"Here is the style guide. The document is from the {domain} domain.\n\n{text}"
        if domain else f"Here is the style guide:\n\n{text}"
    )

    try:
        client = OpenAI(api_key=api_key, base_url=base_url or None)
        response = client.chat.completions.create(
            model=model,
            max_tokens=4096,
            messages=[
                {"role": "system", "content": SG_EXTRACTION_PROMPT},
                {"role": "user", "content": user_message}
            ]
        )
        extracted = response.choices[0].message.content.strip()
    except OpenAIAuthError as e:
        return jsonify({'error': f'Authentication failed — check your API key: {e}'}), 401
    except Exception as e:
        return jsonify({'error': f'Extraction failed: {e}'}), 500

    return jsonify({'extracted': extracted})


@app.route('/')
def index():
    response = make_response(send_from_directory('static', 'index.html'))
    response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
    response.headers['Pragma'] = 'no-cache'
    return response


@app.route('/basic-sg', methods=['GET'])
def basic_sg():
    return jsonify({'text': active_basic_sg, 'is_custom': os.path.isfile(BASIC_SG_PATH)})


@app.route('/set-basic-sg', methods=['POST'])
def set_basic_sg():
    global active_basic_sg
    data = request.json or {}
    text = data.get('text', '').strip()
    if not text:
        return jsonify({'error': 'No text provided.'}), 400
    with open(BASIC_SG_PATH, 'w', encoding='utf-8') as f:
        f.write(text)
    active_basic_sg = text
    return jsonify({'ok': True})


# ── BACKGROUND JOB INFRASTRUCTURE ────────────────────────────────────────────

jobs = {}
jobs_lock = threading.Lock()
PROGRESS_DIR = os.path.join(os.path.dirname(__file__), '.jobs')
os.makedirs(PROGRESS_DIR, exist_ok=True)


def _progress_path(job_id):
    return os.path.join(PROGRESS_DIR, f'job_{job_id}.jsonl')


def _meta_path(job_id):
    return os.path.join(PROGRESS_DIR, f'job_{job_id}.json')


def _translate_chunk(chunk_segments, glossary, style_guide, source_lang, target_lang, api_key, base_url, model, domain='', tb_cap=400):
    if not chunk_segments:
        return [''] * len(chunk_segments)

    glossary_block = ''
    if glossary:
        entries = sorted(glossary.items(), key=lambda x: -len(x[0]))[:tb_cap]
        glossary_block = (
            'GLOSSARY — MANDATORY TERMINOLOGY:\n'
            'The terms below are binding. You MUST use the exact target-language '
            'term listed for each source term, every time it appears, without '
            'exception. Do not paraphrase, synonym-swap, or deviate from these '
            'terms under any circumstances, even if an alternative sounds more natural.\n\n'
            + '\n'.join(f"{src_term} = {tgt_term}" for src_term, tgt_term in entries)
        )

    style_guide_block = (
        'STYLE GUIDE — MANDATORY RULES:\n'
        'Follow the style guide rules below strictly and without exception. '
        'If a rule conflicts with naturalness, the rule takes priority.\n\n'
        + style_guide
    ) if style_guide else ''

    role_line = f"You are a professional translator specialising in {domain}." if domain else "You are a professional translator."
    system_prompt = f"""{role_line} Translate the provided segments from {source_lang} into {target_lang}, accurately and naturally.
{style_guide_block}
{glossary_block}
Respond ONLY as a numbered list in this exact format, one translation per line:
[1] translation here
[2] translation here
No other text, no explanations, no markdown."""

    segment_lines = '\n'.join(f"[{i+1}] {s}" for i, s in enumerate(chunk_segments))
    user_msg = f"Translate these {len(chunk_segments)} segments:\n\n{segment_lines}"

    client = OpenAI(api_key=api_key, base_url=base_url or None)
    response = client.chat.completions.create(
        model=model,
        max_tokens=8096,
        messages=[
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": user_msg}
        ]
    )

    text = response.choices[0].message.content.strip()
    translations = [''] * len(chunk_segments)
    for match in re.finditer(r'\[(\d+)\]\s*(.*?)(?=\n\[|\Z)', text, re.DOTALL):
        idx = int(match.group(1)) - 1
        translation = ' '.join(match.group(2).strip().splitlines()).strip()
        if 0 <= idx < len(chunk_segments):
            translations[idx] = translation

    parsed_count = sum(1 for t in translations if t)
    expected_count = len(chunk_segments)
    if parsed_count != expected_count:
        raise ValueError(
            f"LLM response count mismatch: expected {expected_count}, got {parsed_count}"
        )

    return translations


def _run_job(job_id):
    with jobs_lock:
        job = jobs.get(job_id)
    if not job:
        return

    segs = job['segments']
    chunk_size = job['chunk_size']
    glossary = job['glossary']
    style_guide = job['style_guide']
    source_lang = job['source_lang']
    target_lang = job['target_lang']
    domain = job.get('domain', '')
    tb_cap = job.get('tb_cap', 400)
    api_key = job['api_key']
    base_url = job['base_url']
    model = job['model']
    total = len(segs)

    try:
        with open(_meta_path(job_id), 'w', encoding='utf-8') as f:
            json.dump({
                'job_id': job_id,
                'status': 'running',
                'filename': job.get('filename', 'TranslAID'),
                'source_lang': source_lang,
                'target_lang': target_lang,
                'chunk_size': chunk_size,
                'total': total,
                'mids': [s['mid'] for s in segs],
                'style_guide': style_guide,
                'glossary': glossary,
            }, f, ensure_ascii=False)
    except Exception as e:
        print(f'[WARN] Could not write job metadata: {e}')

    progress_file = _progress_path(job_id)

    for i in range(0, total, chunk_size):
        with jobs_lock:
            if job['status'] == 'cancelled':
                return

        chunk = segs[i:i + chunk_size]
        chunk_num = i // chunk_size + 1
        total_chunks = (total + chunk_size - 1) // chunk_size
        chunk_sources = [s['source'] for s in chunk]

        with jobs_lock:
            job['current_chunk'] = chunk_num
            job['total_chunks'] = total_chunks

        try:
            translations = _translate_chunk(
                chunk_sources, glossary, style_guide, source_lang, target_lang,
                api_key, base_url, model, domain=domain, tb_cap=tb_cap
            )
        except OpenAIAPIError as e:
            msg = getattr(e, 'message', str(e))
            translations = [''] * len(chunk)
            with jobs_lock:
                job['chunk_errors'].append({
                    'chunk': chunk_num, 'error': msg
                })
        except Exception as e:
            translations = [''] * len(chunk)
            with jobs_lock:
                job['chunk_errors'].append({
                    'chunk': chunk_num, 'error': str(e)
                })

        chunk_results = []
        with jobs_lock:
            for j, seg in enumerate(chunk):
                target = translations[j] if j < len(translations) else ''
                entry = {'mid': seg['mid'], 'source': seg['source'], 'target': target}
                job['results'].append(entry)
                chunk_results.append(entry)
                if not target:
                    job['failed_mids'].append(str(seg['mid']))
            job['done'] = len(job['results'])

        def _append_to_disk(path, entries):
            try:
                with open(path, 'a', encoding='utf-8') as f:
                    for entry in entries:
                        f.write(json.dumps(entry, ensure_ascii=False) + '\n')
            except Exception:
                pass

        disk_thread = threading.Thread(
            target=_append_to_disk, args=(progress_file, chunk_results), daemon=True
        )
        disk_thread.start()

    with jobs_lock:
        job['status'] = 'completed'

    try:
        meta_file = _meta_path(job_id)
        if os.path.isfile(meta_file):
            with open(meta_file, 'r+', encoding='utf-8') as f:
                meta = json.load(f)
                meta['status'] = 'completed'
                f.seek(0)
                json.dump(meta, f, ensure_ascii=False)
                f.truncate()
    except Exception as e:
        print(f'[WARN] Could not update job metadata status: {e}')


_TRIAL_SEGMENT_CAP = 30


@app.route('/start-job', methods=['POST'])
def start_job():
    data = request.get_json()
    segments = data.get('segments', [])
    if not segments:
        return jsonify({'error': 'No segments provided'}), 400

    api_key = data.get('api_key', '').strip()
    base_url = data.get('base_url', '').strip()
    model = data.get('model', '').strip()

    if not api_key:
        return jsonify({'error': 'API key is required. Open Settings (⚙) and enter your key.'}), 400
    if not model:
        return jsonify({'error': 'Model name is required. Open Settings (⚙) and enter a model.'}), 400

    license_key, _inst, ls_instance_id = _read_licence_fields()
    trial_limited = True
    if license_key and ls_instance_id:
        trial_limited = not _validate_licence_remote(license_key, ls_instance_id)

    original_total = len(segments)
    if trial_limited and len(segments) > _TRIAL_SEGMENT_CAP:
        segments = segments[:_TRIAL_SEGMENT_CAP]

    sg_provided = data.get('styleGuide', '').strip()

    job_id = data.get('resumeJobId') or uuid.uuid4().hex[:12]
    job = {
        'status': 'running',
        'segments': segments,
        'chunk_size': data.get('chunkSize', 20),
        'glossary': data.get('glossary', {}),
        'style_guide': sg_provided if sg_provided else active_basic_sg,
        'source_lang': data.get('sourceLang', '').strip() or 'the source language',
        'target_lang': data.get('targetLang', '').strip() or 'the target language',
        'domain': data.get('domain', '').strip(),
        'tb_cap': data.get('tb_cap', 400),
        'filename': data.get('filename', 'TranslAID'),
        'api_key': api_key,
        'base_url': base_url,
        'model': model,
        'results': [],
        'failed_mids': [],
        'chunk_errors': [],
        'done': 0,
        'total': len(segments),
        'current_chunk': 0,
        'total_chunks': 0,
        'started_at': time.time(),
    }

    with jobs_lock:
        jobs[job_id] = job

    thread = threading.Thread(target=_run_job, args=(job_id,), daemon=True)
    thread.start()

    return jsonify({
        'job_id': job_id,
        'total': len(segments),
        'trial_limited': trial_limited,
        'original_total': original_total,
    })


@app.route('/job-status/<job_id>')
def job_status(job_id):
    with jobs_lock:
        job = jobs.get(job_id)
    if not job:
        return jsonify({'error': 'Job not found'}), 404

    with jobs_lock:
        return jsonify({
            'status': job['status'],
            'done': job['done'],
            'total': job['total'],
            'current_chunk': job['current_chunk'],
            'total_chunks': job['total_chunks'],
            'results': job['results'],
            'failed_mids': job['failed_mids'],
            'chunk_errors': job['chunk_errors'],
        })


@app.route('/check-interrupted')
def check_interrupted():
    found = []
    try:
        for fname in os.listdir(PROGRESS_DIR):
            if not fname.startswith('job_') or not fname.endswith('.jsonl'):
                continue
            job_id = fname[4:-6]

            with jobs_lock:
                if job_id in jobs and jobs[job_id]['status'] == 'running':
                    continue

            path = os.path.join(PROGRESS_DIR, fname)
            results = []
            try:
                with open(path, encoding='utf-8') as f:
                    for line in f:
                        line = line.strip()
                        if line:
                            results.append(json.loads(line))
            except Exception:
                continue

            if not results:
                continue

            meta_file = _meta_path(job_id)
            meta = None
            if os.path.isfile(meta_file):
                try:
                    with open(meta_file, encoding='utf-8') as f:
                        meta = json.load(f)
                except Exception:
                    pass

            if meta is None:
                try:
                    os.remove(path)
                except Exception:
                    pass
                continue

            if meta.get('status') == 'completed':
                continue

            total = meta.get('total')
            if total and len(results) >= total:
                try:
                    meta['status'] = 'completed'
                    with open(meta_file, 'w', encoding='utf-8') as f:
                        json.dump(meta, f, ensure_ascii=False)
                except Exception:
                    pass
                continue

            entry = {
                'job_id': job_id,
                'results': results,
                'done': len(results),
                'total': meta.get('total') if meta else None,
                'filename': meta.get('filename') if meta else None,
                'source_lang': meta.get('source_lang') if meta else None,
                'target_lang': meta.get('target_lang') if meta else None,
                'chunk_size': meta.get('chunk_size') if meta else None,
                'style_guide': meta.get('style_guide', '') if meta else None,
                'glossary': meta.get('glossary', {}) if meta else None,
                'mids': meta.get('mids') if meta else None,
                'has_meta': meta is not None,
            }
            found.append(entry)
    except Exception:
        pass

    if len(found) > 1:
        found.sort(key=lambda e: os.path.getmtime(
            os.path.join(PROGRESS_DIR, f'job_{e["job_id"]}.jsonl')
        ), reverse=True)
        found = [found[0]]

    with jobs_lock:
        running = None
        for jid, j in jobs.items():
            if j['status'] == 'running':
                running = {
                    'job_id': jid,
                    'done': j['done'],
                    'total': j['total'],
                    'results': j['results'],
                }
                break

    return jsonify({'running': running, 'interrupted': found})


@app.route('/discard-job/<job_id>', methods=['POST'])
def discard_job(job_id):
    for path in (_progress_path(job_id), _meta_path(job_id)):
        try:
            os.remove(path)
        except FileNotFoundError:
            pass
    with jobs_lock:
        job = jobs.pop(job_id, None)
        if job:
            job['status'] = 'cancelled'
    return jsonify({'ok': True})


@app.route('/translate', methods=['POST'])
def translate():
    data = request.get_json()
    segments = data.get('segments', [])
    glossary = data.get('glossary', {})
    target_lang_code = data.get('targetLangCode', '').strip().lower()
    sg_provided = data.get('styleGuide', '').strip()
    domain = data.get('domain', '').strip()
    tb_cap = data.get('tb_cap', 400)

    api_key = data.get('api_key', '').strip()
    base_url = data.get('base_url', '').strip()
    model = data.get('model', '').strip()

    if not api_key:
        return jsonify({'error': 'API key is required. Open Settings (⚙) and enter your key.'}), 400
    if not model:
        return jsonify({'error': 'Model name is required. Open Settings (⚙) and enter a model.'}), 400

    if sg_provided:
        style_guide = sg_provided
    else:
        style_guide = active_basic_sg

    source_lang = data.get('sourceLang', '').strip() or 'the source language'
    target_lang = data.get('targetLang', '').strip() or 'the target language'

    if not segments:
        return jsonify({'error': 'No segments provided'}), 400

   # Build glossary block
    glossary_block = ''
    if glossary:
        entries = sorted(glossary.items(), key=lambda x: -len(x[0]))[:tb_cap]
        glossary_block = (
            'GLOSSARY — MANDATORY TERMINOLOGY:\n'
            'The terms below are binding. You MUST use the exact target-language '
            'term listed for each source term, every time it appears, without '
            'exception. Do not paraphrase, synonym-swap, or deviate from these '
            'terms under any circumstances, even if an alternative sounds more natural.\n\n'
            + '\n'.join(f"{src_term} = {tgt_term}" for src_term, tgt_term in entries)
        )

    style_guide_block = (
        'STYLE GUIDE — MANDATORY RULES:\n'
        'Follow the style guide rules below strictly and without exception. '
        'If a rule conflicts with naturalness, the rule takes priority.\n\n'
        + style_guide
    ) if style_guide else ''

    role_line = f"You are a professional translator specialising in {domain}." if domain else "You are a professional translator."
    system_prompt = f"""{role_line} Translate the provided segments from {source_lang} into {target_lang}, accurately and naturally.
{style_guide_block}
{glossary_block}
Respond ONLY as a numbered list in this exact format, one translation per line:
[1] translation here
[2] translation here
No other text, no explanations, no markdown."""

    segment_lines = '\n'.join(f"[{i+1}] {s}" for i, s in enumerate(segments))
    user_msg = f"Translate these {len(segments)} segments:\n\n{segment_lines}"

    try:
        client = OpenAI(api_key=api_key, base_url=base_url or None)
        response = client.chat.completions.create(
            model=model,
            max_tokens=8096,
            messages=[
                {"role": "system", "content": system_prompt},
                {"role": "user", "content": user_msg}
            ]
        )
    except OpenAIAuthError as e:
        return jsonify({'error': f'Authentication failed — check your API key: {e}'}), 401
    except OpenAIAPIError as e:
        msg = getattr(e, 'message', str(e))
        if 'credit' in msg.lower() or 'quota' in msg.lower() or 'insufficient' in msg.lower():
            return jsonify({'error': 'API quota exhausted. Please check your account credits and try again.'}), 502
        return jsonify({'error': f'Translation API error: {msg}'}), 502

    text = response.choices[0].message.content.strip()

    translations = [''] * len(segments)
    for match in re.finditer(r'\[(\d+)\]\s*(.*?)(?=\n\[|\Z)', text, re.DOTALL):
        idx = int(match.group(1)) - 1
        translation = ' '.join(match.group(2).strip().splitlines()).strip()
        if 0 <= idx < len(segments):
            translations[idx] = translation

    parsed_count = sum(1 for t in translations if t)
    expected_count = len(segments)
    if parsed_count != expected_count:
        return jsonify({
            'error': f'LLM response count mismatch: expected {expected_count}, got {parsed_count}'
        }), 502

    return jsonify({'translations': translations})


# ── LICENCE HELPERS ──────────────────────────────────────────────────────────

def _read_licence_fields():
    try:
        with open(SETTINGS_PATH, 'r', encoding='utf-8') as f:
            data = json.load(f)
    except (FileNotFoundError, json.JSONDecodeError):
        data = {}
    return (
        data.get('license_key', '').strip(),
        data.get('instance_id', '').strip(),
        data.get('ls_instance_id', '').strip(),
    )


def _validate_licence_remote(license_key, ls_instance_id):
    payload = json.dumps({
        'action': 'validate',
        'license_key': license_key,
        'ls_instance_id': ls_instance_id,
    }).encode()
    req = urllib.request.Request(
        ACTIVATE_URL,
        data=payload,
        headers={'Content-Type': 'application/json'},
        method='POST',
    )
    try:
        resp = urllib.request.urlopen(req, timeout=10)
        body = json.loads(resp.read().decode())
        return body.get('status') == 'active'
    except Exception:
        return False


_VALID_OUTPUTS = {
    'sdlxliff': {'sdlxliff', 'xliff12', 'xliff20', 'tmx'},
    'mqxliff':  {'mqxliff',  'xliff12', 'xliff20', 'tmx'},
    'xliff1':   {'xliff12', 'xliff20', 'tmx'},
    'xliff2':   {'xliff12', 'xliff20', 'tmx'},
}

_EXT_MAP = {
    'sdlxliff': 'sdlxliff',
    'mqxliff':  'mqxliff',
    'xliff12':  'xliff',
    'xliff20':  'xliff',
    'tmx':      'tmx',
}


@app.route('/export', methods=['POST'])
def export_file():
    license_key, _inst, ls_instance_id = _read_licence_fields()
    if not license_key or not ls_instance_id:
        return jsonify({'error': 'No active licence. Please activate a licence key in Settings.'}), 403
    if not _validate_licence_remote(license_key, ls_instance_id):
        return jsonify({'error': 'Licence is no longer valid. Please check your licence in Settings.'}), 403

    body = request.json or {}
    output_format = body.get('output_format', '').strip()
    detected_format = body.get('detected_format', '').strip()
    raw_xml = body.get('raw_xml', '')
    segs = body.get('segments', [])
    source_lang = body.get('source_lang', 'en')
    target_lang = body.get('target_lang', 'en')
    filename = body.get('filename', 'output.xml')

    allowed = _VALID_OUTPUTS.get(detected_format)
    if allowed and output_format not in allowed:
        return jsonify({'error': f'Output format {output_format} is not valid for {detected_format} input.'}), 400

    if output_format == 'sdlxliff' and detected_format == 'sdlxliff':
        output = generators.generate_sdlxliff(raw_xml, segs)
    elif output_format == 'mqxliff' and detected_format == 'mqxliff':
        output = generators.generate_mqxliff(raw_xml, segs)
    elif output_format == 'sdlxliff' and detected_format != 'sdlxliff':
        output = generators.generate_standalone_xliff12(segs, source_lang, target_lang)
    elif output_format == 'mqxliff' and detected_format != 'mqxliff':
        output = generators.generate_standalone_xliff12(segs, source_lang, target_lang)
    elif output_format == 'xliff12':
        output = generators.generate_standalone_xliff12(segs, source_lang, target_lang)
    elif output_format == 'xliff20':
        output = generators.generate_standalone_xliff20(segs, source_lang, target_lang)
    elif output_format == 'tmx':
        output = generators.generate_tmx(segs, source_lang, target_lang)
    else:
        output = generators.generate_tmx(segs, source_lang, target_lang)

    resp = make_response(output)
    resp.headers['Content-Type'] = 'application/xml; charset=utf-8'
    resp.headers['Content-Disposition'] = f'attachment; filename="{filename}"'
    return resp


if __name__ == '__main__':
    import sys
    import psutil
    from waitress import serve

    def acquire_lock():
        if os.path.exists(LOCK_FILE):
            try:
                with open(LOCK_FILE, 'r') as f:
                    existing_pid = int(f.read().strip())
                if psutil.pid_exists(existing_pid):
                    proc = psutil.Process(existing_pid)
                    if 'python' in proc.name().lower():
                        return False
            except Exception:
                pass
            try:
                os.remove(LOCK_FILE)
            except Exception:
                pass
        with open(LOCK_FILE, 'w') as f:
            f.write(str(os.getpid()))
        return True

    if not acquire_lock():
        print("\n[ERROR] I'M TranslAID is already running. Only one instance is allowed.\n")
        sys.exit(1)

    # Ensure a stable instance_id exists for licence activation
    try:
        with open(SETTINGS_PATH, 'r', encoding='utf-8') as f:
            _cfg = json.load(f)
    except (FileNotFoundError, json.JSONDecodeError):
        _cfg = {}
    if not _cfg.get('instance_id'):
        _cfg['instance_id'] = str(uuid.uuid4())
        with open(SETTINGS_PATH, 'w', encoding='utf-8') as f:
            json.dump(_cfg, f, indent=2, ensure_ascii=False)

    env_port = os.environ.get('PTI_PORT', '').strip()
    if env_port:
        _server_port = int(env_port)
    else:
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sock.bind(('127.0.0.1', 0))
        _server_port = sock.getsockname()[1]
        sock.close()

    with open(PORT_FILE, 'w') as f:
        f.write(str(_server_port))

    print(f"\n[OK] I'M TranslAID is running at: http://localhost:{_server_port}")
    print(f"     Close this window to shut down the app.\n")
    try:
        serve(app, host='127.0.0.1', port=_server_port, threads=4)
    finally:
        for f in (LOCK_FILE, PORT_FILE):
            try:
                if os.path.exists(f):
                    os.remove(f)
            except Exception:
                pass
