"""
Internal Link Manager
Manages automatic internal linking for SEO articles.

Actions:
    sync <sitemap_url>  - Fetch sitemap.xml and save URL mappings
    list                - Display all saved entries
    apply <article>     - Insert internal links into article

Exit codes:
    0: Success
    1: Usage error
    2: Network/HTTP error
    3: File error
    4: Unexpected error
"""
import argparse
import re
import sys
import unicodedata
from pathlib import Path
from typing import Optional
from urllib.parse import urlparse
from xml.etree import ElementTree

import requests
import yaml
from bs4 import BeautifulSoup

# Constants
LINKS_FILE = ".seokit-links.yaml"
REQUEST_TIMEOUT = 10
LINKS_PER_1000_WORDS = 2
MIN_WORD_DISTANCE = 150
SECTION_PRIORITY = ["body", "conclusion", "faq", "introduction"]
MAX_SITEMAP_URLS = 500  # Limit to prevent DoS
USER_AGENT = "SEOKit/1.0 Internal Link Manager"


def normalize_text(text: str) -> str:
    """Normalize Unicode text for consistent matching."""
    return unicodedata.normalize("NFC", text.lower().strip())


def count_words(text: str) -> int:
    """Count words in text."""
    return len(re.findall(r"\S+", text))


def get_word_positions(text: str) -> list[tuple[int, int, int]]:
    """Get (word_index, start_char, end_char) for each word."""
    positions = []
    for i, match in enumerate(re.finditer(r"\S+", text)):
        positions.append((i, match.start(), match.end()))
    return positions


def char_to_word_position(text: str, char_pos: int) -> int:
    """Convert character position to word position."""
    words_before = count_words(text[:char_pos])
    return words_before


def is_in_heading(content: str, pos: int) -> bool:
    """Check if position is inside a markdown heading (H1-H6)."""
    # Find line containing position
    line_start = content.rfind("\n", 0, pos) + 1
    line_end = content.find("\n", pos)
    if line_end == -1:
        line_end = len(content)
    line = content[line_start:line_end]
    return bool(re.match(r"^\s*#{1,6}\s", line))


def is_in_code_block(content: str, pos: int) -> bool:
    """Check if position is inside a fenced code block."""
    # Count ``` before position
    before = content[:pos]
    fence_count = len(re.findall(r"^```", before, re.MULTILINE))
    # Odd count means inside code block
    return fence_count % 2 == 1


def is_already_linked(content: str, pos: int, keyword_len: int) -> bool:
    """Check if position is already inside a markdown link."""
    # Check if within [...](...) pattern
    # Look for [ before and ]( after
    before = content[max(0, pos - 200):pos]
    after = content[pos:pos + keyword_len + 200]

    # Check if we're inside link text [text]
    open_bracket = before.rfind("[")
    close_bracket = before.rfind("]")
    if open_bracket > close_bracket:
        # Found [ without matching ]
        close_after = after.find("](")
        if close_after != -1:
            return True

    # Check if we're inside link URL (url)
    open_paren = before.rfind("](")
    close_paren = before.rfind(")")
    if open_paren > close_paren:
        return True

    return False


def get_section_type(content: str, pos: int) -> str:
    """Determine section type based on position."""
    before = content[:pos].lower()

    # Check common section headers
    if re.search(r"#+\s*(faq|câu hỏi|hỏi đáp)", before, re.IGNORECASE):
        last_faq = max(
            (m.end() for m in re.finditer(r"#+\s*(faq|câu hỏi|hỏi đáp)", before, re.IGNORECASE)),
            default=0
        )
        # Check if another section started after FAQ
        next_heading = re.search(r"\n#+\s", content[last_faq:pos])
        if not next_heading:
            return "faq"

    if re.search(r"#+\s*(kết luận|conclusion|tổng kết)", before, re.IGNORECASE):
        return "conclusion"

    if re.search(r"#+\s*(giới thiệu|introduction|mở đầu)", before, re.IGNORECASE):
        # Check if we're still in intro section
        intro_pos = max(
            (m.end() for m in re.finditer(r"#+\s*(giới thiệu|introduction|mở đầu)", before, re.IGNORECASE)),
            default=0
        )
        next_heading = re.search(r"\n#+\s", content[intro_pos:pos])
        if not next_heading:
            return "introduction"

    return "body"


def find_keyword_matches(content: str, keyword: str) -> list[tuple[int, int]]:
    """Find all exact matches of keyword in content (case-insensitive for Vietnamese)."""
    matches = []
    normalized_content = normalize_text(content)
    normalized_keyword = normalize_text(keyword)

    # Use word boundary matching
    pattern = r"\b" + re.escape(normalized_keyword) + r"\b"

    for match in re.finditer(pattern, normalized_content):
        # Map back to original positions
        matches.append((match.start(), match.end()))

    return matches


def extract_keyword_from_title(title: str) -> str:
    """Extract main keyword from page title."""
    # Remove common suffixes like " | Brand Name", " - Site Name"
    title = re.split(r"\s*[|\-–—]\s*", title)[0].strip()

    # Remove common prefixes
    title = re.sub(r"^(hướng dẫn|cách|top|best|review|đánh giá)\s+", "", title, flags=re.IGNORECASE)

    return title.strip()


def validate_url(url: str) -> bool:
    """Validate URL scheme and block private IPs."""
    try:
        parsed = urlparse(url)
        # Only allow http/https
        if parsed.scheme not in ("http", "https"):
            return False
        # Block localhost and private IP patterns
        host = parsed.hostname or ""
        if host in ("localhost", "127.0.0.1", "0.0.0.0"):
            return False
        if host.startswith("192.168.") or host.startswith("10."):
            return False
        if host.startswith("172.") and 16 <= int(host.split(".")[1]) <= 31:
            return False
        return True
    except Exception:
        return False


def safe_parse_xml(xml_content: bytes) -> ElementTree.Element:
    """Parse XML safely, blocking XXE attacks."""
    # Create parser that disables external entities
    parser = ElementTree.XMLParser()
    # Parse without resolving external entities
    return ElementTree.fromstring(xml_content, parser=parser)


def fetch_sitemap_urls(sitemap_url: str, depth: int = 0) -> list[str]:
    """Fetch and parse sitemap.xml to extract URLs. Handles sitemap index files."""
    if depth > 2:  # Prevent infinite recursion
        return []

    if not validate_url(sitemap_url):
        raise ValueError(f"Invalid or blocked URL: {sitemap_url}")

    indent = "  " * depth
    print(f"{indent}Fetching sitemap: {sitemap_url}")

    headers = {"User-Agent": USER_AGENT}
    response = requests.get(sitemap_url, timeout=REQUEST_TIMEOUT, headers=headers)
    response.raise_for_status()

    # Parse XML safely
    root = safe_parse_xml(response.content)

    # Handle different namespace formats
    namespaces = {"sm": "http://www.sitemaps.org/schemas/sitemap/0.9"}

    # Check if this is a sitemap index (contains <sitemap> elements)
    child_sitemaps = root.findall(".//sm:sitemap/sm:loc", namespaces)
    if not child_sitemaps:
        child_sitemaps = root.findall(".//sitemap/loc")

    if child_sitemaps:
        # This is a sitemap index - fetch each child sitemap
        print(f"{indent}  Found sitemap index with {len(child_sitemaps)} child sitemaps")
        all_urls = []
        for sitemap_loc in child_sitemaps:
            if sitemap_loc.text and len(all_urls) < MAX_SITEMAP_URLS:
                child_url = sitemap_loc.text.strip()
                child_urls = fetch_sitemap_urls(child_url, depth + 1)
                all_urls.extend(child_urls)
        return all_urls[:MAX_SITEMAP_URLS]

    # Regular sitemap - extract page URLs
    urls = []
    # Try with namespace
    for loc in root.findall(".//sm:url/sm:loc", namespaces):
        if loc.text:
            urls.append(loc.text.strip())

    # Fallback without namespace
    if not urls:
        for loc in root.findall(".//url/loc"):
            if loc.text:
                urls.append(loc.text.strip())

    # Also try direct loc elements (simple sitemaps)
    if not urls:
        for loc in root.findall(".//sm:loc", namespaces):
            if loc.text:
                urls.append(loc.text.strip())
        if not urls:
            for loc in root.findall(".//loc"):
                if loc.text:
                    urls.append(loc.text.strip())

    # Limit URLs to prevent DoS
    if len(urls) > MAX_SITEMAP_URLS:
        print(f"{indent}  Warning: Limiting to first {MAX_SITEMAP_URLS} URLs (found {len(urls)})")
        urls = urls[:MAX_SITEMAP_URLS]

    return urls


def fetch_page_title(url: str) -> Optional[str]:
    """Fetch page and extract title tag."""
    if not validate_url(url):
        print(f"  Warning: Skipping invalid URL: {url}")
        return None

    try:
        headers = {"User-Agent": USER_AGENT}
        response = requests.get(url, timeout=REQUEST_TIMEOUT, headers=headers)
        response.raise_for_status()
        soup = BeautifulSoup(response.content, "html.parser")
        title_tag = soup.find("title")
        if title_tag:
            return title_tag.get_text().strip()
    except Exception as e:
        print(f"  Warning: Failed to fetch {url}: {e}")
    return None


def sync_sitemap(sitemap_url: str) -> dict:
    """
    Fetch sitemap and build URL-keyword mapping.

    Returns:
        dict with stats: total_urls, successful, failed
    """
    # Validate URL
    if not sitemap_url.endswith(".xml"):
        if not sitemap_url.endswith("/"):
            sitemap_url += "/"
        sitemap_url += "sitemap.xml"

    urls = fetch_sitemap_urls(sitemap_url)
    print(f"Found {len(urls)} URLs in sitemap\n")

    entries = []
    failed = 0

    for i, url in enumerate(urls, 1):
        print(f"[{i}/{len(urls)}] Processing: {url[:60]}...")
        title = fetch_page_title(url)

        if title:
            keyword = extract_keyword_from_title(title)
            entries.append({
                "url": url,
                "title": title,
                "keyword": keyword
            })
            print(f"  ✓ Keyword: {keyword}")
        else:
            failed += 1
            print("  ✗ Failed to extract title")

    # Save to YAML
    links_path = Path.cwd() / LINKS_FILE
    with open(links_path, "w", encoding="utf-8") as f:
        yaml.dump({"entries": entries}, f, allow_unicode=True, default_flow_style=False)

    print(f"\n✓ Saved {len(entries)} entries to {links_path}")

    return {
        "total_urls": len(urls),
        "successful": len(entries),
        "failed": failed,
        "output_file": str(links_path)
    }


def list_entries() -> None:
    """Display all entries from .seokit-links.yaml."""
    links_path = Path.cwd() / LINKS_FILE

    if not links_path.exists():
        print(f"No links file found. Run 'sync' first to create {LINKS_FILE}")
        sys.exit(1)

    with open(links_path, "r", encoding="utf-8") as f:
        data = yaml.safe_load(f)

    entries = data.get("entries", [])

    if not entries:
        print("No entries found in links file.")
        return

    print(f"Found {len(entries)} entries:\n")
    print("-" * 80)

    for i, entry in enumerate(entries, 1):
        print(f"{i}. {entry.get('keyword', 'N/A')}")
        print(f"   URL: {entry.get('url', 'N/A')}")
        print(f"   Title: {entry.get('title', 'N/A')[:50]}...")
        print()

    print("-" * 80)
    print(f"Total: {len(entries)} entries")


def apply_links(article_path: str) -> dict:
    """
    Insert internal links into article based on keyword matching.

    Returns:
        dict with stats: links_inserted, max_links, words_count
    """
    links_path = Path.cwd() / LINKS_FILE
    article_file = Path(article_path)

    if not links_path.exists():
        print(f"Error: {LINKS_FILE} not found. Run 'sync' first.")
        sys.exit(1)

    if not article_file.exists():
        print(f"Error: Article file not found: {article_path}")
        sys.exit(1)

    # Load entries
    with open(links_path, "r", encoding="utf-8") as f:
        data = yaml.safe_load(f)
    entries = data.get("entries", [])

    if not entries:
        print("No entries in links file.")
        return {"links_inserted": 0, "max_links": 0, "words_count": 0}

    # Read article
    with open(article_file, "r", encoding="utf-8") as f:
        content = f.read()

    # Calculate max links
    word_count = count_words(content)
    max_links = (word_count // 1000) * LINKS_PER_1000_WORDS
    max_links = max(1, max_links)  # At least 1 link

    print(f"Article: {word_count} words → max {max_links} links allowed\n")

    # Sort entries by keyword length (longer first to avoid partial matches)
    entries_sorted = sorted(entries, key=lambda e: len(e.get("keyword", "")), reverse=True)

    links_inserted = 0
    used_urls = set()
    link_positions = []  # Track word positions of inserted links

    # Group matches by section priority
    for section in SECTION_PRIORITY:
        if links_inserted >= max_links:
            break

        for entry in entries_sorted:
            if links_inserted >= max_links:
                break

            url = entry.get("url", "")
            keyword = entry.get("keyword", "")

            if not keyword or url in used_urls:
                continue

            # Find matches
            matches = find_keyword_matches(content, keyword)

            for start, end in matches:
                if links_inserted >= max_links:
                    break

                # Check section
                current_section = get_section_type(content, start)
                if current_section != section:
                    continue

                # Check exclusions
                if is_in_heading(content, start):
                    continue
                if is_in_code_block(content, start):
                    continue
                if is_already_linked(content, start, len(keyword)):
                    continue

                # Check distance from other links
                word_pos = char_to_word_position(content, start)
                too_close = False
                for prev_pos in link_positions:
                    if abs(word_pos - prev_pos) < MIN_WORD_DISTANCE:
                        too_close = True
                        break

                if too_close:
                    continue

                # Get exact original text (preserve case)
                original_text = content[start:end]

                # Insert link
                link_text = f"[{original_text}]({url})"
                content = content[:start] + link_text + content[end:]

                # Note: Content length changed, but we break after first match
                # per keyword, and matches are recalculated for each keyword.

                links_inserted += 1
                used_urls.add(url)
                link_positions.append(word_pos)

                print(f"  ✓ Inserted: [{original_text}]({url[:40]}...)")
                print(f"    Section: {section}, Position: word {word_pos}")

                # Only one link per keyword
                break

    # Save updated article
    if links_inserted > 0:
        with open(article_file, "w", encoding="utf-8") as f:
            f.write(content)
        print(f"\n✓ Saved article with {links_inserted} new links")
    else:
        print("\nNo links inserted (no matching keywords found or all positions excluded)")

    return {
        "links_inserted": links_inserted,
        "max_links": max_links,
        "words_count": word_count,
        "used_urls": list(used_urls)
    }


def main():
    """CLI entry point."""
    parser = argparse.ArgumentParser(
        description="Internal Link Manager for SEOKit",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""
Examples:
    python internal-link-manager.py sync https://example.com/sitemap.xml
    python internal-link-manager.py list
    python internal-link-manager.py apply ./article.md
        """
    )

    subparsers = parser.add_subparsers(dest="action", help="Action to perform")

    # sync command
    sync_parser = subparsers.add_parser("sync", help="Sync sitemap URLs")
    sync_parser.add_argument("sitemap_url", help="URL to sitemap.xml")

    # list command
    subparsers.add_parser("list", help="List all saved entries")

    # apply command
    apply_parser = subparsers.add_parser("apply", help="Apply links to article")
    apply_parser.add_argument("article_path", help="Path to markdown article")

    args = parser.parse_args()

    if not args.action:
        parser.print_help()
        sys.exit(1)

    try:
        if args.action == "sync":
            stats = sync_sitemap(args.sitemap_url)
            print(f"\nSync complete: {stats['successful']}/{stats['total_urls']} URLs processed")

        elif args.action == "list":
            list_entries()

        elif args.action == "apply":
            stats = apply_links(args.article_path)
            print(f"\nApply complete: {stats['links_inserted']}/{stats['max_links']} links inserted")

    except requests.exceptions.Timeout:
        print(f"\nError: Request timed out after {REQUEST_TIMEOUT}s", file=sys.stderr)
        sys.exit(2)

    except requests.exceptions.RequestException as e:
        print(f"\nError: HTTP request failed: {e}", file=sys.stderr)
        sys.exit(2)

    except (OSError, IOError) as e:
        print(f"\nError: File operation failed: {e}", file=sys.stderr)
        sys.exit(3)

    except Exception as e:
        print(f"\nUnexpected error: {e}", file=sys.stderr)
        sys.exit(4)


if __name__ == "__main__":
    main()
