#!/usr/bin/env python3
"""
MkDocs Markdown Combiner - Multi-Mode Tool

Mode 1: mkdocs.yml mode (default for .yml/.yaml input)
  python3 mkdocs-combine.py mkdocs.yml
  - Extracts navigation hierarchy from mkdocs.yml
  - Reads files one by one from specified chapters
  - Preserves YAML frontmatter from first chapter (for EPUB metadata)
  - Shifts headings according to nav level
  - Fixes internal links
  - Outputs to stdout

Mode 2: Single file mode
  python3 mkdocs-combine.py input.md --level 1
  - Reads input.md file
  - Shifts headings by specified level
  - Fixes internal links
  - Outputs to stdout

Mode 3: Summary mode
  python3 mkdocs-combine.py mkdocs.yml --mode summary --exclude p3-summary.md
  - Extracts nav-ordered file list from mkdocs.yml
  - Concatenates raw chapter content (no heading shifts, no link rewrites)
  - Inserts <!-- FILE: filename.md --> markers between chapters
  - Optionally writes heading index JSON to --index-output
  - Outputs concatenated text to stdout

Usage:
  # Mode 1: Process mkdocs.yml for EPUB
  python3 mkdocs-combine.py mkdocs.yml > combined.md

  # Mode 2: Process single file with heading adjustment
  python3 mkdocs-combine.py chapter.md --level 1 > output.md

  # Mode 3: Prepare source for AI summarization
  python3 mkdocs-combine.py mkdocs.yml --mode summary \\
    --exclude p3-summary.md --index-output build/heading_index.json \\
    > build/summary_source.md
"""

import argparse
import json
import re
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple

import yaml


def load_yaml_config(config_path: str) -> Dict[str, Any]:
    """Load MkDocs YAML configuration with graceful error handling."""
    config_file = Path(config_path)

    if not config_file.exists():
        raise FileNotFoundError(f"Config file not found: {config_file}")

    with open(config_file, "r", encoding="utf-8") as f:
        try:
            config = yaml.safe_load(f)
        except yaml.constructor.ConstructorError:
            # Handle MkDocs-style custom tags (e.g. !ENV) and Python object tags.
            f.seek(0)

            class SkipUnknownLoader(yaml.SafeLoader):
                pass

            def _construct_unknown(loader: Any, node: Any) -> Any:
                # Best-effort: preserve the underlying YAML structure but ignore the tag.
                # For mkdocs-combine we only care about basic fields like `nav`.
                if isinstance(node, yaml.ScalarNode):
                    return loader.construct_scalar(node)
                if isinstance(node, yaml.SequenceNode):
                    return loader.construct_sequence(node)
                if isinstance(node, yaml.MappingNode):
                    return loader.construct_mapping(node)
                return None

            def skip_unknown(loader: Any, tag_suffix: str, node: Any) -> Any:
                return _construct_unknown(loader, node)

            SkipUnknownLoader.add_multi_constructor("!python", skip_unknown)
            SkipUnknownLoader.add_multi_constructor(
                "tag:yaml.org,2002:python/", skip_unknown
            )
            # MkDocs commonly uses custom tags like !ENV (provided via pyyaml-env-tag).
            # Treat any unknown "!Something" tag as the underlying YAML node.
            SkipUnknownLoader.add_multi_constructor("!", skip_unknown)

            config = yaml.load(f, Loader=SkipUnknownLoader)

    return config


def extract_nav_items(
    nav_config: List[Any], level: int = 0
) -> List[Tuple[str, str, int]]:
    """
    Extract navigation items with hierarchy levels.

    Returns list of tuples: (title, filepath, level)
    - level 0: top-level chapters
    - level 1+: nested sections
    """
    items: List[Tuple[str, str, int]] = []

    for item in nav_config:
        if isinstance(item, str):
            if item.endswith(".md"):
                items.append(("", item, level))
        elif isinstance(item, dict):
            for section_title, section_items in item.items():
                if isinstance(section_items, str) and "://" in section_items:
                    # External link
                    items.append((section_title, section_items, level))
                    continue

                # Section header
                items.append((section_title, "", level))

                # Nested items
                if isinstance(section_items, list):
                    items.extend(extract_nav_items(section_items, level + 1))

    return items


def extract_frontmatter(content: str) -> Dict[str, Any]:
    """Extract YAML frontmatter as dictionary."""
    if not content.startswith("---\n"):
        return {}

    end_match = re.search(r"\n---\n", content[4:])
    if not end_match:
        return {}

    frontmatter_text = content[4 : end_match.start() + 4]

    try:
        frontmatter = yaml.safe_load(frontmatter_text)
        return frontmatter if isinstance(frontmatter, dict) else {}
    except yaml.YAMLError:
        return {}


def remove_frontmatter(content: str) -> str:
    """Remove YAML frontmatter from markdown."""
    if content.startswith("---\n"):
        end_match = re.search(r"\n---\n", content[4:])
        if end_match:
            return content[end_match.end() + 4 :]
    return content


def get_git_file_dates(file_path: Path) -> Tuple[str, str]:
    """
    Retrieve file dates from Git history.

    Returns tuple: (created_date, updated_date) in format %d.%m.%Y
    - created_date: date of first commit (--follow to track renames)
    - updated_date: date of most recent commit
    """
    import subprocess

    try:
        # Determine git repository root (file's parent, or walk up to find .git)
        # Since file_path might be in a different repo than cwd, we need to find
        # the repo that contains this file
        git_dir = file_path.parent
        while git_dir.parent != git_dir:  # not at filesystem root
            if (git_dir / ".git").exists():
                break
            git_dir = git_dir.parent

        # Make file path relative to git root
        try:
            rel_path = file_path.relative_to(git_dir)
        except ValueError:
            # File is not under git_dir, can't get git dates
            return "", ""

        # Get creation date (first commit following file history)
        result_created = subprocess.run(
            [
                "git",
                "-C",
                str(git_dir),
                "log",
                "--follow",
                "--diff-filter=A",
                "--format=%ai",
                "--reverse",
                "--",
                str(rel_path),
            ],
            capture_output=True,
            text=True,
            timeout=5,
        )

        created_date = None
        if result_created.stdout:
            # Extract first line and parse date (format: YYYY-MM-DD HH:MM:SS +ZZZZ)
            first_line = result_created.stdout.strip().split("\n")[0]
            if first_line:
                date_part = first_line.split()[0]
                created_date = format_git_date(date_part)

        # Get update date (most recent commit)
        result_updated = subprocess.run(
            [
                "git",
                "-C",
                str(git_dir),
                "log",
                "--follow",
                "--format=%ai",
                "--max-count=1",
                "--",
                str(rel_path),
            ],
            capture_output=True,
            text=True,
            timeout=5,
        )

        updated_date = None
        if result_updated.stdout:
            first_line = result_updated.stdout.strip()
            if first_line:
                date_part = first_line.split()[0]
                updated_date = format_git_date(date_part)

        return created_date or "", updated_date or ""

    except (subprocess.TimeoutExpired, FileNotFoundError, Exception) as e:
        print(
            f"[WARN] Could not retrieve Git dates for {file_path}: {e}", file=sys.stderr
        )
        return "", ""


def format_git_date(date_str: str) -> str:
    """Convert date from YYYY-MM-DD format to %d.%m.%Y format."""
    try:
        from datetime import datetime

        dt = datetime.strptime(date_str, "%Y-%m-%d")
        return dt.strftime("%d.%m.%Y")
    except ValueError:
        return ""


def format_dates_from_frontmatter(
    frontmatter: Dict[str, Any], file_path: Path | None = None
) -> str:
    """
    Format dates from frontmatter as italic text.

    Falls back to Git history if dates are not specified in frontmatter.
    Date format: %d.%m.%Y
    """
    dates: List[str] = []

    # Get Git dates as fallback
    git_created = ""
    git_updated = ""
    if file_path:
        git_created, git_updated = get_git_file_dates(file_path)

    # Use frontmatter date or fall back to Git date
    created = frontmatter.get("created") or git_created
    published = frontmatter.get("published") or git_created
    updated = frontmatter.get("updated") or git_updated

    if created:
        dates.append(f"Создано: {created}")
    if published and published != created:
        dates.append(f"Опубликовано: {published}")
    if updated and updated != created:
        dates.append(f"Обновлено: {updated}")

    if dates:
        # Ensure blank lines so Pandoc treats markers and content as separate blocks
        # This helps the Lua filter detect opening/content/closing correctly.
        return f"/// chapter-dates\n{' '.join(dates)}\n///"
    return ""


def adjust_heading_levels(content: str, level: int) -> Tuple[str, List[Tuple[int, Optional[str]]]]:
    """
    Shift markdown heading levels by specified amount.
    Returns (new_content, headings) where headings is a list of (pos, anchor or None)
    """
    headings: List[Tuple[int, Optional[str]]] = []

    def replace_heading(match: "re.Match[str]") -> str:
        hashes = match.group(1)
        title = match.group(2)
        anchor = match.group(3) or ""
        current_level = len(hashes)
        new_level = max(1, min(current_level + level, 6))
        new_hashes = "#" * new_level
        # Extract anchor name if present
        anchor_name = None
        if anchor:
            m = re.match(r"\s*\{#([^}]+)\}", anchor)
            if m:
                anchor_name = m.group(1)
        headings.append((match.start(), anchor_name))
        return f"{new_hashes} {title}{anchor}"

    pattern = r"^(#{1,6})\s+(.+?)(\s*\{#[^}]+\})?$"
    new_content = re.sub(pattern, replace_heading, content, flags=re.MULTILINE)
    return new_content, headings


def add_anchor_to_first_h1(content: str, anchor_id: str) -> str:
    """
    Add anchor to the first h1 heading if it doesn't have one already.

    Transforms:
    - # Heading -> # Heading {#anchor-id}
    - # Heading {#existing} -> # Heading {#existing} (unchanged if has anchor)
    """
    # Find the first h1 heading
    match = re.search(r"^(# [^\n]*?)(\s*\{#[^}]+\})?\n", content, re.MULTILINE)
    if match:
        heading = match.group(1)
        existing_anchor = match.group(2)

        # If no anchor, add one
        if not existing_anchor:
            new_heading = f"{heading} {{{anchor_id}}}\n"
            return content[: match.start()] + new_heading + content[match.end() :]

    return content


def fix_internal_links(content: str, current_file: str) -> str:
    """
    Fix internal markdown links for combined documents.

    Transformations:
    - [text](file.md) -> [text](#file-md)
    - [text](file.md#anchor) -> [text](#anchor)
    - [text](#anchor) -> [text](#anchor) (unchanged)
    """

    def replace_link(match: "re.Match[str]") -> str:
        is_image = match.group(1) == "!"
        text = match.group(2)
        url = match.group(3)

        # Skip image links and external links
        if is_image or url.startswith(("http://", "https://", "//", "mailto:")):
            return match.group(0)

        # Handle internal links
        if "#" in url:
            if url.startswith("#"):
                # Anchor-only link (unchanged)
                new_url = url
            else:
                # File with anchor: file.md#anchor -> #anchor
                _, anchor_part = url.split("#", 1)
                # Just use the anchor part, ignore the file
                new_url = f"#{anchor_part}"
        else:
            # File without anchor: file.md -> #file-md
            if url.endswith(".md"):
                file_base = url.replace(".md", "-md").replace("/", "-")
                new_url = f"#{file_base}"
            else:
                new_url = url

        return f"[{text}]({new_url})"

    pattern = r"(!?)\[([^\]]+)\]\(([^)]+)\)"
    return re.sub(pattern, replace_link, content)


def extract_first_heading(content: str) -> str:
    """Extract first heading from markdown content."""
    content_clean = remove_frontmatter(content)
    match = re.search(
        r"^#{1,6}\s+(.+?)(?:\s*\{#[^}]+\})?$", content_clean, re.MULTILINE
    )
    return match.group(1).strip() if match else ""


def replace_details_with_source_link(
    content: str, site_url: str, filepath: str, headings: List[Tuple[int, Optional[str]]]
) -> str:
    """
    Replace content inside /// details blocks with source link.

    Transforms:
        /// details | Title
        <content>
        ///

    Into:
        /// details | Title
        <source_link>
        ///

    Uses headings array to find nearest anchor above each block.
    """
    # Pattern: capture opening line with optional title, then any content until closing ///
    # Group 1: optional title part after 'details' (e.g., ' | Исходник')
    # Group 2: inner content (to be replaced)
    details_pattern = r"^///\s*details([^\n]*)\n+(.*?)\n+///"

    # Keep filename normalized for URL building
    filename_without_md = filepath.replace(".md", "").replace("\\", "/")

    def find_nearest_anchor_above(pos: int) -> Optional[str]:
        anchor: Optional[str] = None
        for hpos, hanchor in headings:
            if hpos < pos and hanchor:
                anchor = hanchor
            elif hpos >= pos:
                break
        return anchor

    def replace_details(match: "re.Match[str]") -> str:
        title_part = match.group(1)  # Includes everything after 'details' on first line
        match_pos = match.start()
        anchor = find_nearest_anchor_above(match_pos)
        if anchor:
            source_link = f"{site_url.rstrip('/')}/{filename_without_md}#{anchor}"
        else:
            source_link = f"{site_url.rstrip('/')}/{filename_without_md}"
        # Preserve delimiters and title, replace inner content with the link only
        return f"/// details{title_part}\n\n<{source_link}>\n\n///"

    # Apply details-block replacement (DOTALL so '.' matches newlines)
    return re.sub(
        details_pattern, replace_details, content, flags=re.MULTILINE | re.DOTALL
    )


def mode_mkdocs(config_path: str) -> str:
    """
    Mode 1: Process mkdocs.yml configuration.

    - Extracts nav hierarchy
    - Reads files one by one
    - Shifts headings according to level
    - Fixes links
    - Returns combined content
    """
    config = load_yaml_config(config_path)

    if "docs_dir" not in config:
        raise ValueError("Config missing 'docs_dir'")
    if "nav" not in config:
        raise ValueError("Config missing 'nav'")
    if "site_url" not in config:
        raise ValueError("Config missing 'site_url'")

    config_dir = Path(config_path).parent
    docs_dir = config_dir / config["docs_dir"]

    if not docs_dir.exists():
        raise FileNotFoundError(f"Docs directory not found: {docs_dir}")

    nav_items = extract_nav_items(config["nav"])
    combined: List[str] = []
    first_content = True

    for title, filepath, level in nav_items:
        # External links
        if filepath and "://" in filepath:
            level_hashes = "#" * (level + 1)
            combined.append(f"\n{level_hashes} [{title}]({filepath})\n")
            continue

        # Section headers (no filepath)
        if not filepath:
            level_hashes = "#" * (level + 1)
            combined.append(f"\n{level_hashes} {title}\n\n")
            continue

        # File content
        file_path = docs_dir / filepath
        if not file_path.exists():
            print(f"[WARN] File not found: {file_path}", file=sys.stderr)
            continue

        print(f"[INFO] Processing: {filepath} (level {level})", file=sys.stderr)

        try:
            content = file_path.read_text(encoding="utf-8")

            # Extract frontmatter for dates and metadata
            frontmatter = extract_frontmatter(content)

            # Extract dates for display at end of section
            dates_text = format_dates_from_frontmatter(frontmatter, file_path)

            # Extract title from first heading
            title_from_file = extract_first_heading(content)
            if not title_from_file:
                title_from_file = filepath.replace(".md", "").replace("-", " ").title()

            print(f"[INFO]   Title: {title_from_file}", file=sys.stderr)

            # Calculate anchor ID for the file
            anchor_id = f"#{filepath.replace('.md', '-md').replace('/', '-')}"

            # Process content
            content = remove_frontmatter(content)
            content = content.lstrip(
                "\n"
            )  # Remove leading blank lines after frontmatter

            content = add_anchor_to_first_h1(
                content, anchor_id
            )  # Add anchor to original h1
            content, headings = adjust_heading_levels(content, level)
            content = replace_details_with_source_link(
                content, config["site_url"], filepath, headings
            )
            content = fix_internal_links(content, filepath)

            # Combine with dates
            # First content section has no leading newline; subsequent ones do
            if first_content:
                output_section = content
                first_content = False
            else:
                output_section = f"\n{content}"

            # Add dates at the end if present
            if dates_text:
                output_section += f"\n{dates_text}\n"

            combined.append(output_section)

        except Exception as e:
            print(f"[ERROR] Processing {filepath}: {e}", file=sys.stderr)
            continue

    # Combine all sections
    result = "".join(combined)

    return result


def extract_headings_with_anchors(content: str) -> List[Tuple[str, str]]:
    """
    Extract all headings with {#anchor} from markdown content.

    Returns list of tuples: (heading_text, anchor)
    Only returns headings that have explicit anchors.
    """
    pattern = r"^#{1,6}\s+(.+?)\s*\{#([^}]+)\}\s*$"
    return re.findall(pattern, content, re.MULTILINE)


def flatten_nav_files(nav_config: List[Any]) -> List[str]:
    """
    Flatten navigation config into ordered list of .md filenames.

    Recursively walks the nav tree and returns only file entries,
    skipping section headers and external links.
    """
    files: List[str] = []
    for item in nav_config:
        if isinstance(item, str):
            if item.endswith(".md"):
                files.append(item)
        elif isinstance(item, dict):
            for section_items in item.values():
                if isinstance(section_items, str):
                    if section_items.endswith(".md"):
                        files.append(section_items)
                elif isinstance(section_items, list):
                    files.extend(flatten_nav_files(section_items))
    return files


def mode_summary(
    config_path: str,
    exclude: Optional[List[str]] = None,
    index_output: Optional[str] = None,
) -> str:
    """
    Mode 3: Prepare source text for AI summarization.

    - Extracts nav-ordered file list from mkdocs.yml
    - Skips files listed in --exclude
    - Concatenates raw chapter content (no heading shifts, no link rewrites)
    - Inserts <!-- FILE: filename.md --> markers between chapters
    - Optionally writes heading index JSON to index_output
    - Returns concatenated text
    """
    config = load_yaml_config(config_path)

    if "docs_dir" not in config:
        raise ValueError("Config missing 'docs_dir'")
    if "nav" not in config:
        raise ValueError("Config missing 'nav'")

    config_dir = Path(config_path).parent
    docs_dir = config_dir / config["docs_dir"]

    if not docs_dir.exists():
        raise FileNotFoundError(f"Docs directory not found: {docs_dir}")

    exclude_set = set(exclude or [])
    nav_files = flatten_nav_files(config["nav"])
    heading_index: Dict[str, str] = {}  # "filename_without_md#anchor" -> "Heading text"
    combined: List[str] = []

    for filepath in nav_files:
        if filepath in exclude_set:
            print(f"[INFO] Skipping excluded: {filepath}", file=sys.stderr)
            continue

        file_path = docs_dir / filepath
        if not file_path.exists():
            print(f"[WARN] File not found: {file_path}", file=sys.stderr)
            continue

        print(f"[INFO] Processing: {filepath}", file=sys.stderr)

        try:
            content = file_path.read_text(encoding="utf-8")
            content_body = remove_frontmatter(content).lstrip("\n")

            # Extract headings for the index
            filename_base = filepath.replace(".md", "")
            headings = extract_headings_with_anchors(content_body)
            for heading_text, anchor in headings:
                key = f"{filename_base}#{anchor}"
                heading_index[key] = heading_text

            # Add file marker and content
            combined.append(f"<!-- FILE: {filepath} -->\n{content_body}")

        except Exception as e:
            print(f"[ERROR] Processing {filepath}: {e}", file=sys.stderr)
            continue

    # Write heading index if requested
    if index_output:
        index_path = Path(index_output)
        index_path.parent.mkdir(parents=True, exist_ok=True)
        with open(index_path, "w", encoding="utf-8") as f:
            json.dump(heading_index, f, ensure_ascii=False, indent=2)
        print(f"[INFO] Heading index: {index_path} ({len(heading_index)} entries)", file=sys.stderr)

    return "\n\n".join(combined)


def mode_single_file(input_file: str, level: int) -> str:
    """
    Mode 2: Process single markdown file.

    - Reads input.md
    - Shifts headings by specified level
    - Fixes internal links
    - Returns processed content
    """
    file_path = Path(input_file)

    if not file_path.exists():
        raise FileNotFoundError(f"Input file not found: {file_path}")

    print(f"[INFO] Processing: {input_file} (level shift: {level})", file=sys.stderr)

    content = file_path.read_text(encoding="utf-8")

    # Extract title for logging
    title_from_file = extract_first_heading(content)
    print(f"[INFO]   Title: {title_from_file}", file=sys.stderr)

    # Process content
    content = remove_frontmatter(content)
    content, _ = adjust_heading_levels(content, level)
    content = fix_internal_links(content, input_file)

    return content


def main():
    """Main entry point with argument parsing."""
    parser = argparse.ArgumentParser(
        description="MkDocs Markdown Combiner - Multi-mode tool",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=__doc__,
    )

    # Required: input file
    parser.add_argument(
        "input", help="Input file: either mkdocs.yml or a markdown file"
    )

    # Optional: explicit mode selection
    parser.add_argument(
        "--mode",
        choices=["mkdocs", "single", "summary"],
        default=None,
        help="Processing mode: mkdocs (EPUB combine), single (one file), "
        "summary (AI summarization source). Auto-detected if omitted.",
    )

    # Optional: level for single file mode
    parser.add_argument(
        "--level",
        type=int,
        default=0,
        help="Heading level shift for single file mode (default: 0)",
    )

    # Optional: exclude files for summary mode
    parser.add_argument(
        "--exclude",
        nargs="*",
        default=[],
        help="Filenames to exclude in summary mode (e.g. p3-summary.md)",
    )

    # Optional: heading index output for summary mode
    parser.add_argument(
        "--index-output",
        default=None,
        help="Path to write heading index JSON (summary mode only)",
    )

    args = parser.parse_args()

    # Auto-detect mode if not specified
    mode = args.mode
    if mode is None:
        if args.input.endswith(".yml") or args.input.endswith(".yaml"):
            mode = "mkdocs"
        else:
            mode = "single"

    try:
        if mode == "mkdocs":
            print("[INFO] Mode: mkdocs (EPUB combine)", file=sys.stderr)
            output = mode_mkdocs(args.input)
        elif mode == "summary":
            print("[INFO] Mode: summary (AI summarization source)", file=sys.stderr)
            output = mode_summary(args.input, args.exclude, args.index_output)
        else:
            print(
                f"[INFO] Mode: single file (level shift {args.level})",
                file=sys.stderr,
            )
            output = mode_single_file(args.input, args.level)

        # Output to stdout
        print(output, end="")
        print("[INFO] Complete", file=sys.stderr)

    except Exception as e:
        print(f"[ERROR] {e}", file=sys.stderr)
        import traceback

        traceback.print_exc()
        sys.exit(1)


if __name__ == "__main__":
    main()
