#!/usr/bin/env python3
"""Bounded FetchFox comments export. Python 3.10+, standard library only."""
import argparse
import csv
import io
import json
import math
import os
import re
import sys
import tempfile
from datetime import datetime, timezone
from http.client import HTTPException
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.parse import urlsplit, parse_qs
from urllib.request import Request, HTTPRedirectHandler, build_opener

API = 'https://api.fetchfox.net'
MAX_BYTES = 5 * 1024 * 1024
COLUMNS = ['post_url', 'id', 'text', 'author', 'author_name', 'likes', 'replies', 'timestamp']


class ExportError(Exception):
    pass


class NoRedirects(HTTPRedirectHandler):
    def redirect_request(self, req, fp, code, msg, headers, newurl):
        return None


def validate_url(platform, value):
    try:
        url = urlsplit(value)
        if url.scheme != 'https' or url.username or url.password or url.port not in (None, 443):
            raise ValueError()
        hosts = {'instagram': {'instagram.com', 'www.instagram.com', 'm.instagram.com'},
                 'facebook': {'facebook.com', 'www.facebook.com', 'm.facebook.com', 'web.facebook.com'}}
        if url.hostname not in hosts[platform] or len(value) > 2000 or 'REPLACE_' in value:
            raise ValueError()
        if platform == 'instagram':
            valid = re.fullmatch(r'/(?:p|reel|reels|tv)/[A-Za-z0-9_-]+/?', url.path)
        else:
            valid = (url.path.rstrip('/') == '/watch' and re.fullmatch(r'\d+', parse_qs(url.query).get('v', [''])[0])) or re.fullmatch(r'/(?:reel/\d+|[A-Za-z0-9_.-]+/(?:posts|videos)/[A-Za-z0-9]+)(?:/)?', url.path)
        if not valid:
            raise ValueError()
    except (ValueError, KeyError):
        raise ExportError('Use a direct public HTTPS post, reel or video URL for the selected platform.') from None
    return value


def fetch_page(platform, key, payload):
    request = Request(API + '/' + platform + '/comments', data=json.dumps(payload).encode(), method='POST',
                      headers={'Content-Type': 'application/json', 'x-access-key': key})
    try:
        with build_opener(NoRedirects()).open(request, timeout=90) as response:
            raw = response.read(MAX_BYTES + 1)
            if len(raw) > MAX_BYTES:
                raise ExportError('Response exceeded the example size limit.')
            body = json.loads(raw)
            charge = response.headers.get('x-credits-used')
            return body, int(charge) if charge and charge.isdigit() else None
    except HTTPError as error:
        retry = error.headers.get('Retry-After', '')
        advice = (' Retry-After: ' + retry[:80] + '.') if error.code == 429 and re.fullmatch(r'[\w ,:+-]{1,80}', retry) else ''
        raise ExportError(f'HTTP {error.code}.{advice} No automatic retry was made.') from None
    except (URLError, TimeoutError, OSError, ValueError, HTTPException):
        raise ExportError('Request failed or returned invalid JSON. No automatic retry was made.') from None


def validate_page(body, limit):
    if not isinstance(body, dict) or body.get('success') is not True or not isinstance(body.get('data'), dict):
        raise ExportError('The API did not return a successful comment collection.')
    data = body['data']
    comments = data.get('comments')
    if not isinstance(comments, list) or len(comments) > limit or type(data.get('hasMore')) is not bool:
        raise ExportError('Invalid comments or pagination metadata.')
    ids = set()
    for row in comments:
        if not isinstance(row, dict) or not isinstance(row.get('id'), str) or not row['id'].strip() or not isinstance(row.get('text'), str) or row['id'] in ids:
            raise ExportError('A comment is malformed or repeated within a page.')
        ids.add(row['id'])
        for field in ['likes', 'replyCount', 'timestamp']:
            if row.get(field) is not None and (type(row[field]) is not int or row[field] < 0 or row[field] > 9007199254740991):
                raise ExportError('A comment metric is invalid.')
    cursor = data.get('cursor')
    if data['hasMore'] and (not isinstance(cursor, str) or not cursor.strip() or len(cursor) > 16384):
        raise ExportError('The response has no safe continuation.')
    if not comments and (data['hasMore'] or data.get('collectionStatus') != 'exhausted'):
        raise ExportError('An empty result was not confirmed as exhausted.')
    return data


def safe_cell(value, identifier=False):
    if value is None:
        return ''
    if not isinstance(value, (str, int, float, bool)) or isinstance(value, float) and not math.isfinite(value):
        return ''
    text = str(value)
    if identifier and text.isdigit() or isinstance(value, str) and (re.match(r'^[\s\x00-\x1f]*[=+\-@]', text) or re.match(r'^[\x00-\x1f]', text)):
        return "'" + text
    return text


def csv_text(platform, source, comments):
    output = io.StringIO(newline='')
    writer = csv.writer(output, quoting=csv.QUOTE_ALL, lineterminator='\r\n')
    writer.writerow(COLUMNS)
    for item in comments:
        values = [source, item['id'], item['text'], item.get('username') if platform == 'instagram' else item.get('name'),
                  item.get('fullName') if platform == 'instagram' else item.get('name'), item.get('likes'), item.get('replyCount'), item.get('timestamp')]
        writer.writerow([safe_cell(value, identifier=index == 1) for index, value in enumerate(values)])
    return '\ufeff' + output.getvalue()


def atomic_write(path, content):
    temporary = None
    try:
        with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', newline='', dir=path.parent, delete=False) as output:
            temporary = Path(output.name)
            output.write(content)
        temporary.replace(path)
    finally:
        if temporary and temporary.exists():
            temporary.unlink()


def collect(platform, url, key, limit, pages, output, sort_by='popular', request=fetch_page):
    validate_url(platform, url)
    if not 1 <= limit <= 100 or not 1 <= pages <= 5 or sort_by not in ('popular', 'recent'):
        raise ExportError('Use limit 1–100, pages 1–5 and a supported sort order.')
    report_path = output.with_suffix('.report.json')
    if output == report_path or output.exists() or report_path.exists() or not output.parent.is_dir():
        raise ExportError('Choose new CSV and report filenames in an existing directory.')
    comments, ids, cursors = [], set(), set()
    report = {'sourceUrl': url, 'platform': platform, 'startedAt': datetime.now(timezone.utc).isoformat(),
              'pageLimit': pages, 'limitPerPage': limit, 'pages': [], 'stopReason': 'page_budget', 'partial': True}
    cursor = None
    failure = None
    for _ in range(pages):
        payload = {'url': url, 'limit': limit}
        if platform == 'instagram':
            payload['sortBy'] = sort_by
        if cursor:
            payload['cursor'] = cursor
        try:
            body, charge = request(platform, key, payload)
            data = validate_page(body, limit)
        except ExportError as error:
            failure = str(error)
            report.update(stopReason='request_failed', error=failure)
            break
        fresh = [row for row in data['comments'] if row['id'] not in ids]
        comments.extend(fresh)
        ids.update(row['id'] for row in fresh)
        report['pages'].append({'retrievedAt': datetime.now(timezone.utc).isoformat(), 'returned': len(data['comments']),
                                'newRows': len(fresh), 'creditsUsed': charge, 'collectionStatus': data.get('collectionStatus', 'unknown'),
                                'stopReason': data.get('stopReason', 'unknown')})
        if not data['hasMore']:
            report.update(stopReason=data.get('stopReason', 'source_stopped'), partial=data.get('collectionStatus') != 'exhausted')
            break
        next_cursor = data['cursor']
        if next_cursor in cursors or next_cursor == cursor or not fresh:
            report['stopReason'] = 'stalled_continuation'
            break
        cursors.add(next_cursor)
        cursor = next_cursor
        # Retain every accepted page even if a later request fails.
        atomic_write(output, csv_text(platform, url, comments))
    report['rowsSaved'] = len(comments)
    report['coverageNote'] = 'Source exhaustion does not establish complete coverage of comments or replies.'
    if report['pages']:
        atomic_write(output, csv_text(platform, url, comments))
    atomic_write(report_path, json.dumps(report, ensure_ascii=False, indent=2) + '\n')
    if failure:
        raise ExportError(f'{failure} Retained {len(comments)} rows; see {report_path.name}.')
    return report


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument('--platform', choices=['instagram', 'facebook'], required=True)
    parser.add_argument('--url', required=True)
    parser.add_argument('--limit', type=int, default=20)
    parser.add_argument('--pages', type=int, default=1)
    parser.add_argument('--sort-by', choices=['popular', 'recent'], default='popular', help='Instagram only; preserved for every page')
    parser.add_argument('--output', type=Path, default=Path('comments.csv'))
    args = parser.parse_args()
    try:
        key = os.environ.get('FETCHFOX_API_KEY', '').strip()
        if not key:
            raise ExportError('Set FETCHFOX_API_KEY in your environment; do not put it in a URL or command argument.')
        if args.platform == 'facebook' and args.sort_by != 'popular':
            raise ExportError('--sort-by is only supported for Instagram.')
        report = collect(args.platform, args.url, key, args.limit, args.pages, args.output, args.sort_by)
        print(f"Saved {report['rowsSaved']} rows from {len(report['pages'])} successful pages to {args.output}.")
        print(f"Stopped: {report['stopReason']}. Inspect the report before starting another paid collection.")
    except ExportError as error:
        print(str(error), file=sys.stderr)
        return 1
    return 0


if __name__ == '__main__':
    sys.exit(main())
