#!/usr/bin/env python3
"""
archive.py - Site Archiver for Sunset Sites

Thin wrapper around neopig that packages crawl results into a distributable
tar.gz archive. Uses neopig for the actual crawling.

Output structure:
    {domain}-{date}/
        html/                   # Original HTML pages
        markdown/               # Converted markdown (optional)
        vault/                  # Media vault (9-layer hash paths, neopig-compatible)
        neopig.db               # Full neopig database
        neopig/                 # Embedded neopig server
        metadata.json           # Crawl metadata

Usage:
    python archive.py https://discourse-urho3d.github.io/
    python archive.py https://example.com --no-screenshot
"""

import argparse
import asyncio
import json
import logging
import os
import re
import shutil
import signal
import subprocess
import sys
import tarfile
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Dict, Any, List, Optional
from miniuri import Uri

import aiofiles
import aiofiles.os
from bs4 import BeautifulSoup
from tqdm import tqdm

from neopig import NeoPig, setup_logging, rotate_state_file, get_state_file_path
from async_web_fetcher import CrawlMode
from screenshot import ScreenshotConfig
from filevault import hash_to_path

# Optional markdown conversion
try:
    import html2text
    HAS_HTML2TEXT = True
except ImportError:
    HAS_HTML2TEXT = False

logger = logging.getLogger(__name__)


def sanitize_filename(name: str) -> str:
    """Sanitize a string for use as filename. Uses - as separator."""
    name = re.sub(r'[<>:"/\\|?*.]', '-', name)
    name = re.sub(r'-+', '-', name)  # collapse multiple dashes
    name = name.strip('- ')
    return name[:200] if name else 'unnamed'


def url_to_path(url: str) -> str:
    """Convert URL to filesystem path."""
    parsed = Uri(url)
    path = parsed.path.strip('/')
    if not path:
        return 'index.html'
    if path.endswith('.html') or path.endswith('.htm'):
        return path
    if '.' in path.split('/')[-1]:
        return path
    return f"{path}/index.html"


def html_to_markdown(html: str, base_url: str = '', trim_wrapper: bool = False) -> str:
    """Convert HTML to markdown."""
    if not HAS_HTML2TEXT:
        return html
    # Optionally strip nav/header/footer/logo before conversion
    if trim_wrapper:
        from neopig import trim_html_wrapper
        html = trim_html_wrapper(html)
    h = html2text.HTML2Text()
    h.ignore_links = False
    h.ignore_images = False
    h.body_width = 0
    h.unicode_snob = True
    if base_url:
        h.baseurl = base_url
    return h.handle(html)


class SiteArchiver:
    """
    Packages neopig crawl results into a distributable tar.gz archive.

    Uses neopig for crawling, then reads from its vaults to build the archive.
    """

    def __init__(
        self,
        output_dir: str = '.',
        include_screenshots: bool = True,
        include_markdown: bool = True,
        screenshot_config: ScreenshotConfig = None,
        fast_mode: bool = False,
        trim_wrapper: bool = False,
        show_progress: bool = True,
        fresh_start: bool = False,
    ):
        self.output_dir = Path(output_dir)
        self.include_screenshots = include_screenshots
        self.include_markdown = include_markdown and HAS_HTML2TEXT
        self.screenshot_config = screenshot_config or ScreenshotConfig(enabled=include_screenshots)
        self.fast_mode = fast_mode
        self.trim_wrapper = trim_wrapper
        self.show_progress = show_progress
        self.fresh_start = fresh_start

    async def archive(
        self,
        target_url: str,
        depth: int = -1,
        max_pages: int = -1,
        db_path: str = None,
        vault_path: str = None,
        package_only: bool = False,
    ) -> Path:
        """
        Archive a site using neopig and package into tar.gz.
        """
        parsed = Uri(target_url)
        domain = parsed.hostname.lower()
        date_str = datetime.now().strftime('%Y%m%d')
        archive_name = f"{sanitize_filename(domain)}-{date_str}"

        # Use standard neopig data paths - one vault, multiple domains
        if not db_path:
            db_path = "data/neopig.db"
        if not vault_path:
            vault_path = "data/vault"

        # Ensure data directory exists
        Path("data").mkdir(exist_ok=True)

        logger.info(f"Starting archive of {target_url}")
        logger.info(f"Archive name: {archive_name}")

        # Create neopig instance and crawl
        pig = NeoPig(
            db_path=db_path,
            vault_path=vault_path,
            screenshot_config=self.screenshot_config,
            fast_mode=self.fast_mode,
            trim_wrapper=self.trim_wrapper,
        )
        await pig.init()

        # Handle fresh start: rotate state files
        if self.fresh_start:
            pig._clear_state(target_url)
            logger.info("Fresh start: state file rotated")
        else:
            # Load previously crawled pages/media/screenshots from DB (source of truth for resume)
            crawled_pages = await pig.db.get_crawled_page_uris()
            crawled_media = await pig.db.get_crawled_media_uris()
            crawled_screenshots = await pig.db.get_crawled_screenshot_uris()
            logger.info(f"Resume state: {len(crawled_pages)} pages, {len(crawled_media)} media URIs, {len(crawled_screenshots)} screenshotted pages in DB")
            if crawled_pages:
                pig.seen_pages = crawled_pages
            if crawled_media:
                pig.seen_media = crawled_media
            if crawled_screenshots:
                pig.seen_screenshots = crawled_screenshots
        logger.info(f"Screenshots enabled: {pig.screenshot_config.enabled}")

        # Run the crawl (unless package_only)
        stats = {}
        if package_only:
            logger.info("Package-only mode: skipping crawl, packaging existing data")
        else:
            stats = await pig.crawl(
                target_uri=target_url,
                mode=CrawlMode.ALL,
                depth=depth,
                max_pages=max_pages,
                download_media=True,
            )

        # Now package the results
        logger.info("Packaging archive (streaming mode)...")

        tar_path = self.output_dir / f"{archive_name}.tar.gz"

        # Get URL-to-hash mapping for rewriting external URLs to local copies
        logger.info("Loading media URL mappings...")
        url_to_hash = await pig.db.get_all_media_uri_mappings()
        logger.info(f"Loaded {len(url_to_hash)} URL mappings for rewriting")

        def rewrite_urls(html_content: str) -> str:
            """Rewrite external image/media URLs to local archive paths."""
            import re
            def replace_url(match):
                url = match.group(1)
                if url in url_to_hash:
                    md5 = url_to_hash[url]
                    # Get extension from original URL
                    ext = Path(url.split('?')[0]).suffix or '.bin'
                    return match.group(0).replace(url, f'../media/{md5}{ext}')
                return match.group(0)

            # Replace src="url" and href="url" patterns
            html_content = re.sub(r'src=["\']([^"\']+)["\']', replace_url, html_content)
            html_content = re.sub(r'href=["\']([^"\']+\.(jpg|jpeg|png|gif|webp|mp4|webm|svg))["\']',
                                  replace_url, html_content, flags=re.IGNORECASE)
            return html_content

        # Step 1: Load pages from database (has both raw_html and markdown)
        logger.info("Loading pages from database...")
        sitemap = []
        html_contents = {}
        markdown_contents = {}

        pages = await pig.db.get_pages_by_domain(domain)
        for page in pages:
            if not page.get('raw_html'):
                continue
            # Convert URI to path
            uri_path = page.get('path') or ''
            if not uri_path or uri_path == '/':
                rel_path = 'index.html'
            elif uri_path.endswith('.html'):
                rel_path = uri_path.lstrip('/')
            else:
                rel_path = uri_path.strip('/') + '/index.html'

            try:
                content = page['raw_html']
                # Rewrite external URLs to local copies
                content = rewrite_urls(content)
                title = page.get('title') or self._extract_title(content) or rel_path
                sitemap.append({'path': f'html/{rel_path}', 'title': title})
                html_contents[rel_path] = content
                # Store markdown if available
                if page.get('markdown'):
                    markdown_contents[rel_path] = page['markdown']
            except Exception:
                pass

        # Step 2: Create generated files in small temp dir
        local_tmpdir = self.output_dir / '.tmp'
        local_tmpdir.mkdir(parents=True, exist_ok=True)

        with tempfile.TemporaryDirectory(dir=local_tmpdir) as tmpdir:
            tmpdir_path = Path(tmpdir)

            # Write metadata
            metadata = {
                'domain': domain,
                'target_url': target_url,
                'created': datetime.now(timezone.utc).isoformat(),
                'stats': stats,
                'include_screenshots': self.include_screenshots,
                'include_markdown': self.include_markdown,
            }
            (tmpdir_path / 'metadata.json').write_text(json.dumps(metadata, indent=2))

            # Copy state file
            state_file = get_state_file_path(domain)
            if state_file.exists():
                shutil.copy(state_file, tmpdir_path / 'crawl_state.json')
                logger.info(f"Included crawl state for future delta crawls")

            # Write index.html
            self._write_index_html(tmpdir_path, sitemap, domain)

            # Close database BEFORE copying to ensure WAL is checkpointed
            # (SQLite WAL mode keeps data in -wal file until close)
            await pig.db.close()
            logger.info("Database closed, WAL checkpointed")

            # Step 3: Stream everything to tar.gz in one pass
            logger.info("Streaming to archive...")

            # Capture for closure
            show_progress = self.show_progress
            include_markdown = self.include_markdown
            trim_wrapper = self.trim_wrapper
            include_screenshots = self.include_screenshots

            def stream_to_tar():
                with tarfile.open(tar_path, 'w:gz') as tar:
                    # Add generated files first (from temp)
                    for f in tmpdir_path.iterdir():
                        tar.add(f, arcname=f"{archive_name}/{f.name}")

                    # Stream HTML files (with rewritten URLs)
                    html_iter = tqdm(html_contents.items(), desc="HTML", unit="pages", disable=not show_progress)
                    for rel_path_str, content in html_iter:
                        try:
                            html_bytes = content.encode('utf-8')
                            arcname = f"{archive_name}/html/{rel_path_str}"
                            info = tarfile.TarInfo(name=arcname)
                            info.size = len(html_bytes)
                            tar.addfile(info, io.BytesIO(html_bytes))

                            # Use stored markdown (or generate if not available)
                            if include_markdown:
                                md_content = markdown_contents.get(rel_path_str)
                                if not md_content:
                                    md_content = html_to_markdown(content, trim_wrapper=trim_wrapper)
                                md_bytes = md_content.encode('utf-8')
                                md_rel = rel_path_str.replace('.html', '.md')
                                md_arcname = f"{archive_name}/markdown/{md_rel}"
                                md_info = tarfile.TarInfo(name=md_arcname)
                                md_info.size = len(md_bytes)
                                tar.addfile(md_info, io.BytesIO(md_bytes))
                        except Exception as e:
                            logger.debug(f"Error adding HTML {rel_path_str}: {e}")

                    # Stream media files by hash (matching rewritten URLs)
                    hash_vault = Path(vault_path)
                    added_hashes = set()
                    media_iter = tqdm(url_to_hash.items(), desc="Media", unit="files", disable=not show_progress)
                    for url, md5 in media_iter:
                        if md5 in added_hashes:
                            continue
                        # Skip screenshots if not included
                        if not include_screenshots and url.startswith('screenshot:'):
                            continue
                        # Find file in hash vault using 9-layer deep path: vault/ab/cd/.../hash.ext
                        hash_path = hash_vault / hash_to_path(md5)
                        hash_dir = hash_path.parent
                        if hash_dir.exists():
                            for f in hash_dir.iterdir():
                                if f.stem == md5:
                                    try:
                                        # Preserve vault structure for neopig compatibility
                                        rel_path = f.relative_to(hash_vault)
                                        arcname = f"{archive_name}/vault/{rel_path}"
                                        tar.add(f, arcname=arcname)
                                        added_hashes.add(md5)
                                    except Exception as e:
                                        logger.debug(f"Error adding media {f}: {e}")
                                    break

                    # Bundle neopig source files for self-contained crawling
                    neopig_src = Path(__file__).parent
                    neopig_files = [
                        'neopig.py', 'database.py', 'async_web_fetcher.py',
                        'storage.py', 'domain_vault.py', 'screenshot.py',
                        'html2md.py', 'serp.py', 'filevault.py', 'async_filevault.py',
                    ]
                    for pyfile in neopig_files:
                        src_path = neopig_src / pyfile
                        if src_path.exists():
                            tar.add(src_path, arcname=f"{archive_name}/neopig/{pyfile}")

                    # Add static vendor assets (highlight.js for markdown code blocks)
                    vendor_dir = neopig_src / 'static' / 'vendor'
                    if vendor_dir.exists():
                        for asset in vendor_dir.iterdir():
                            if asset.is_file():
                                tar.add(asset, arcname=f"{archive_name}/neopig/static/vendor/{asset.name}")

                    # Add requirements.txt for neopig dependencies
                    req_path = neopig_src / 'requirements.txt'
                    if req_path.exists():
                        tar.add(req_path, arcname=f"{archive_name}/requirements.txt")

                    # Add full neopig database for self-containment
                    db_file = Path(db_path)
                    if db_file.exists():
                        tar.add(db_file, arcname=f"{archive_name}/neopig.db")

            await asyncio.to_thread(stream_to_tar)

        # Clear html_contents to free memory
        html_contents.clear()

        final_size = tar_path.stat().st_size
        logger.info(f"Archive complete: {tar_path} ({final_size / 1024 / 1024:.1f} MB)")

        return tar_path

    def _write_index_html(self, tmpdir: Path, sitemap: list, domain: str):
        """Write index.html with sitemap."""
        html = f'''<!DOCTYPE html>
<html>
<head>
    <title>{domain} Archive</title>
    <style>
        body {{ font-family: sans-serif; max-width: 800px; margin: 40px auto; padding: 0 20px; }}
        h1 {{ color: #333; }}
        ul {{ list-style: none; padding: 0; }}
        li {{ padding: 8px 0; border-bottom: 1px solid #eee; }}
        a {{ color: #0066cc; text-decoration: none; }}
        a:hover {{ text-decoration: underline; }}
    </style>
</head>
<body>
    <h1>{domain} Archive</h1>
    <p>{len(sitemap)} pages archived</p>
    <ul>
'''
        for item in sitemap[:1000]:  # Limit to 1000 in index
            html += f'        <li><a href="{item["path"]}">{item["title"]}</a></li>\n'
        if len(sitemap) > 1000:
            html += f'        <li>... and {len(sitemap) - 1000} more pages</li>\n'
        html += '''    </ul>
</body>
</html>'''
        (tmpdir / 'index.html').write_text(html)

    def _extract_title(self, html: str) -> Optional[str]:
        """Extract title from HTML."""
        try:
            soup = BeautifulSoup(html, 'html.parser')
            if soup.title and soup.title.string:
                return soup.title.string.strip()
            h1 = soup.find('h1')
            if h1:
                return h1.get_text().strip()
        except Exception:
            pass
        return None

def upgrade_neopig_in_archive(archive_path: Path, output_dir: Path = None) -> Path:
    """Replace neopig/ directory in existing archive with current source.

    Uses system tar + pigz for speed (10x faster than Python tarfile).
    Requires temp disk space for extraction (~5-10x compressed size).

    Args:
        archive_path: Path to the tar.gz archive
        output_dir: Directory for temp files (default: cwd)
    """
    import shutil
    import subprocess

    if not archive_path.exists():
        raise FileNotFoundError(f"Archive not found: {archive_path}")

    # Estimate required temp space (compressed * 5 is conservative)
    archive_size = archive_path.stat().st_size
    required_space = archive_size * 5
    logger.info(f"Archive: {archive_size / 1024 / 1024:.0f} MB (need ~{required_space / 1024 / 1024 / 1024:.1f} GB temp space)")

    neopig_src = Path(__file__).parent

    # Check for pigz (parallel gzip) - much faster
    has_pigz = shutil.which('pigz') is not None
    if has_pigz:
        logger.info("Using pigz for parallel compression")

    # Create temp directory for extraction (use output_dir or cwd for space)
    work_dir = Path(output_dir) if output_dir else Path.cwd()
    temp_base = work_dir / '.upgrade_tmp'
    temp_base.mkdir(exist_ok=True)
    with tempfile.TemporaryDirectory(dir=temp_base) as tmpdir:
        tmpdir = Path(tmpdir)

        # Extract archive using system tar (much faster than Python)
        logger.info(f"Extracting archive: {archive_path}")
        subprocess.run(['tar', '-xzf', str(archive_path), '-C', str(tmpdir)], check=True)

        # Find archive root directory
        contents = list(tmpdir.iterdir())
        if len(contents) != 1 or not contents[0].is_dir():
            raise ValueError("Expected single directory in archive")
        archive_root = contents[0]
        archive_name = archive_root.name
        logger.info(f"Archive root: {archive_name}")

        # Remove old neopig directory
        old_neopig = archive_root / 'neopig'
        if old_neopig.exists():
            shutil.rmtree(old_neopig)
            logger.info("Removed old neopig/")

        # Copy new neopig files
        new_neopig = archive_root / 'neopig'
        new_neopig.mkdir()
        neopig_files = [
            'neopig.py', 'database.py', 'async_web_fetcher.py',
            'storage.py', 'domain_vault.py', 'screenshot.py',
            'html2md.py', 'serp.py', 'filevault.py', 'async_filevault.py',
        ]
        for pyfile in neopig_files:
            src = neopig_src / pyfile
            if src.exists():
                shutil.copy2(src, new_neopig / pyfile)
                logger.info(f"  Added: neopig/{pyfile}")

        # Copy static vendor assets
        vendor_src = neopig_src / 'static' / 'vendor'
        if vendor_src.exists():
            vendor_dst = new_neopig / 'static' / 'vendor'
            vendor_dst.mkdir(parents=True, exist_ok=True)
            for asset in vendor_src.iterdir():
                if asset.is_file():
                    shutil.copy2(asset, vendor_dst / asset.name)
                    logger.info(f"  Added: neopig/static/vendor/{asset.name}")

        # Copy requirements.txt
        req_src = neopig_src / 'requirements.txt'
        if req_src.exists():
            shutil.copy2(req_src, archive_root / 'requirements.txt')
            logger.info("  Added: requirements.txt")

        # Repack using system tar (with pigz if available)
        logger.info("Repacking archive...")
        output_path = archive_path.with_suffix('.new.tar.gz')

        if has_pigz:
            # tar + pigz for parallel compression
            with open(output_path, 'wb') as out:
                tar_proc = subprocess.Popen(
                    ['tar', '-cf', '-', '-C', str(tmpdir), archive_name],
                    stdout=subprocess.PIPE
                )
                pigz_proc = subprocess.Popen(
                    ['pigz', '-c'],
                    stdin=tar_proc.stdout,
                    stdout=out
                )
                tar_proc.stdout.close()
                pigz_proc.wait()
                tar_proc.wait()
        else:
            # Standard tar with gzip
            subprocess.run(
                ['tar', '-czf', str(output_path), '-C', str(tmpdir), archive_name],
                check=True
            )

        # Replace original
        logger.info("Replacing original archive...")
        shutil.move(output_path, archive_path)

    # Cleanup temp base
    try:
        temp_base.rmdir()
    except OSError:
        pass

    logger.info(f"Done! Upgraded: {archive_path}")
    return archive_path


async def main():
    parser = argparse.ArgumentParser(
        description="Archive a website for preservation (uses neopig for crawling)",
        epilog="Example: python archive.py https://discourse-urho3d.github.io/"
    )

    parser.add_argument("url", nargs='?', help="URL of the site to archive")
    parser.add_argument("-o", "--output", default=".", help="Output directory for tar.gz")
    parser.add_argument("-d", "--depth", type=int, default=-1, help="Crawl depth: 0=single page, 1=page+links, -1=unlimited")
    parser.add_argument("-p", "--max-pages", type=int, default=-1, help="Max pages (-1 = unlimited)")
    parser.add_argument("--no-screenshot", "--no-screenshots", action="store_true", help="Disable screenshots")
    parser.add_argument("--screenshot-width", type=int, default=1280, help="Screenshot width")
    parser.add_argument("--screenshot-height", type=int, default=1024, help="Screenshot height")
    parser.add_argument("--screenshot-engine", type=str, default=None, help="Screenshot engine")
    parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
    parser.add_argument("--fresh", action="store_true", help="Start fresh (rotates old state files instead of resuming)")
    parser.add_argument("--fast", action="store_true", help="Fast mode: no crawl delay (for sites without robots.txt)")
    parser.add_argument("--package-only", action="store_true", help="Skip crawling, just package existing data from vault")
    parser.add_argument("--serve", action="store_true", help="Start SERP server to watch crawl live")
    parser.add_argument("--port", type=int, default=31337, help="Port for SERP server (default: 31337)")
    parser.add_argument("--backfill-markdown", metavar="DOMAIN", help="Re-process stored HTML for DOMAIN to regenerate markdown with absolute URLs")
    parser.add_argument("--trim-wrapper", action="store_true", help="With --backfill-markdown: strip nav/header/footer/logo before conversion")
    parser.add_argument("--db", default="data/neopig.db", help="Database path (for --backfill-markdown)")
    parser.add_argument("--upgrade-neopig", metavar="TARBALL", help="Upgrade neopig inside an existing archive")
    parser.add_argument("--no-progress", action="store_true", help="Disable progress bars")

    args = parser.parse_args()

    # Validate depth - max 18 before suggesting unlimited
    if args.depth > 18:
        print(f"Error: depth {args.depth} is too high. Use --depth -1 for unlimited crawling.")
        return

    setup_logging(level=logging.DEBUG if args.verbose else logging.INFO)

    # Handle --upgrade-neopig
    if args.upgrade_neopig:
        archive_path = Path(args.upgrade_neopig)
        output_dir = Path(args.output) if args.output != "." else None
        logger.info(f"Upgrading neopig in {archive_path}...")
        upgrade_neopig_in_archive(archive_path, output_dir=output_dir)
        logger.info(f"Done! Archive updated: {archive_path}")
        return

    # Handle --backfill-markdown
    if args.backfill_markdown:
        from neopig import backfill_markdown
        await backfill_markdown(args.db, domain_filter=args.backfill_markdown, trim_wrapper=args.trim_wrapper)
        return

    # URL required for archiving
    if not args.url:
        parser.error("URL required (use --upgrade-neopig or --backfill-markdown for other operations)")

    if not HAS_HTML2TEXT:
        logger.warning("html2text not installed - markdown disabled")

    screenshot_config = ScreenshotConfig(
        enabled=not args.no_screenshot,
        width=args.screenshot_width,
        height=args.screenshot_height,
        engine=args.screenshot_engine,
        full_page=True,  # Archive captures full page by default
    )

    archiver = SiteArchiver(
        output_dir=args.output,
        include_screenshots=not args.no_screenshot,
        include_markdown=True,
        screenshot_config=screenshot_config,
        fast_mode=args.fast,
        trim_wrapper=args.trim_wrapper,
        show_progress=not args.no_progress,
        fresh_start=args.fresh,
    )

    # Start SERP server if requested
    serp_process = None
    if args.serve:
        serp_script = Path(__file__).parent / 'serp.py'
        if serp_script.exists():
            serp_cmd = [
                sys.executable, str(serp_script),
                '--port', str(args.port),
                '--db', 'data/neopig.db',
                '--vault', 'data/vault',
            ]
            serp_process = subprocess.Popen(
                serp_cmd,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
            )
            logger.info(f"SERP server started at http://localhost:{args.port}")
            logger.info("Watch the crawl live - pages appear as they're indexed!")
        else:
            logger.warning("serp.py not found - --serve disabled")

    try:
        archive_path = await archiver.archive(
            target_url=args.url,
            depth=args.depth,
            max_pages=args.max_pages,
            package_only=args.package_only,
        )

        print(f"\nArchive created: {archive_path}")
        print(f"Extract with: tar -xzf {archive_path.name}")
    finally:
        # Cleanup SERP server
        if serp_process:
            logger.info("Stopping SERP server...")
            serp_process.terminate()
            try:
                serp_process.wait(timeout=5)
            except subprocess.TimeoutExpired:
                serp_process.kill()


if __name__ == "__main__":
    asyncio.run(main())
