#!/usr/bin/env bash
# -----------------------------------------------------------------------------------------------------------
# Copyright (c) 2026 Huawei Technologies Co., Ltd.
# This program is free software, you can redistribute it and/or modify it under the terms and conditions of
# CANN Open Software License Agreement Version 2.0 (the "License").
# Please refer to the License for details. You may not use this file except in compliance with the License.
# THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND, EITHER EXPRESS OR IMPLIED,
# INCLUDING BUT NOT LIMITED TO NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE.
# See LICENSE in the root of the software repository for the full text of the License.
# -----------------------------------------------------------------------------------------------------------

set -euo pipefail

LYCHEE_VERSION="${LYCHEE_VERSION:-0.23.0}"
MARKDOWN_LINK_CHECK_FULL="${MARKDOWN_LINK_CHECK_FULL:-0}"
MARKDOWN_LINK_CHECK_DRY_RUN="${MARKDOWN_LINK_CHECK_DRY_RUN:-0}"
MARKDOWN_LINK_CHECK_VERBOSE="${MARKDOWN_LINK_CHECK_VERBOSE:-0}"
MARKDOWN_LINK_CHECK_ONLINE="${MARKDOWN_LINK_CHECK_ONLINE:-0}"
MARKDOWN_LINK_CHECK_START=$SECONDS

REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
cd "$REPO_ROOT"

CONFIG_FILE="$REPO_ROOT/markdown-link-check.toml"
if [ ! -f "$CONFIG_FILE" ]; then
    echo "[Markdown check] Missing config: $CONFIG_FILE" >&2
    exit 1
fi

usage() {
    cat <<'EOF'
Usage: scripts/markdown_link_check.sh [--full|--all] [markdown-file...]

By default, checks changed Markdown files and Markdown files that link to them.
Set MARKDOWN_LINK_CHECK_FULL=1 or pass --full to scan tracked Markdown files in the repository.
Set MARKDOWN_LINK_CHECK_DRY_RUN=1 to print the computed scan set only.
Set MARKDOWN_LINK_CHECK_LYCHEE to use a preinstalled lychee binary.
Set MARKDOWN_LINK_CHECK_ONLINE=1 to check remote HTTP(S) links; remote links are skipped by default.
Set MARKDOWN_LINK_CHECK_LYCHEE_ARCHIVE_URL, MARKDOWN_LINK_CHECK_CARGO_BINSTALL_INSTALLER_URL, or
MARKDOWN_LINK_CHECK_LYCHEE_PKG_URL to use mirrors.
EOF
}

markdown_check_duration() {
    printf '%ss' "$((SECONDS - MARKDOWN_LINK_CHECK_START))"
}

download_lychee_archive() {
    local target archive_url tmp_dir version_placeholder target_placeholder

    case "$(uname -s):$(uname -m)" in
        Linux:x86_64|Linux:amd64)
            target="x86_64-unknown-linux-gnu"
            ;;
        Linux:aarch64|Linux:arm64)
            target="aarch64-unknown-linux-gnu"
            ;;
        *)
            return 1
            ;;
    esac

    if [ -n "${MARKDOWN_LINK_CHECK_LYCHEE_ARCHIVE_URL:-}" ]; then
        archive_url="$MARKDOWN_LINK_CHECK_LYCHEE_ARCHIVE_URL"
    else
        archive_url="https://cann-3rd.obs.cn-north-4.myhuaweicloud.com/lychee-v{version}/lychee-{target}.tar.gz"
    fi
    version_placeholder="{version}"
    target_placeholder="{target}"
    archive_url="${archive_url//$version_placeholder/$LYCHEE_VERSION}"
    archive_url="${archive_url//$target_placeholder/$target}"
    tmp_dir=$(mktemp -d)
    echo "[Markdown check] Downloading lychee@$LYCHEE_VERSION from $archive_url..." >&2
    if ! curl -L --proto '=https' --tlsv1.2 -sSf "$archive_url" -o "$tmp_dir/lychee.tar.gz"; then
        rm -rf "$tmp_dir"
        return 1
    fi
    if ! tar -xzf "$tmp_dir/lychee.tar.gz" -C "$tmp_dir" lychee; then
        rm -rf "$tmp_dir"
        return 1
    fi
    install -m 0755 "$tmp_dir/lychee" "$LYCHEE_BIN"
    rm -rf "$tmp_dir"
    return 0
}

FULL_SCAN="$MARKDOWN_LINK_CHECK_FULL"
INPUT_FILES=()
while [ "$#" -gt 0 ]; do
    case "$1" in
        --all|--full)
            FULL_SCAN=1
            ;;
        -h|--help)
            usage
            exit 0
            ;;
        *)
            INPUT_FILES+=("$1")
            ;;
    esac
    shift
done

find_lychee() {
    if [ -n "${MARKDOWN_LINK_CHECK_LYCHEE:-}" ] && [ -x "$MARKDOWN_LINK_CHECK_LYCHEE" ]; then
        printf '%s\n' "$MARKDOWN_LINK_CHECK_LYCHEE"
        return 0
    fi

    if command -v lychee >/dev/null 2>&1; then
        command -v lychee
        return 0
    fi

    GIT_COMMON_DIR=$(git rev-parse --git-common-dir 2>/dev/null || printf '.git')
    case "$GIT_COMMON_DIR" in
        /*) CACHE_ROOT="$GIT_COMMON_DIR/markdown-link-check" ;;
        *) CACHE_ROOT="$REPO_ROOT/$GIT_COMMON_DIR/markdown-link-check" ;;
    esac

    CARGO_HOME_DIR="$CACHE_ROOT/cargo"
    CARGO_BIN="$CARGO_HOME_DIR/bin"
    LYCHEE_BIN="$CARGO_BIN/lychee-$LYCHEE_VERSION"

    if [ -x "$LYCHEE_BIN" ]; then
        printf '%s\n' "$LYCHEE_BIN"
        return 0
    fi

    mkdir -p "$CARGO_BIN"

    local lock_dir lock_wait
    lock_dir="$CACHE_ROOT/lychee-$LYCHEE_VERSION.install.lock"
    lock_wait=0
    until mkdir "$lock_dir" 2>/dev/null; do
        if [ -x "$LYCHEE_BIN" ]; then
            printf '%s\n' "$LYCHEE_BIN"
            return 0
        fi
        if [ "$lock_wait" -ge 300 ]; then
            echo "[Markdown check] Timed out waiting for lychee install lock: $lock_dir" >&2
            exit 1
        fi
        sleep 1
        lock_wait=$((lock_wait + 1))
    done

    if (
        trap 'rmdir "$lock_dir"' EXIT
        export CARGO_HOME="$CARGO_HOME_DIR"
        export PATH="$CARGO_BIN:$PATH"

        if [ -x "$LYCHEE_BIN" ]; then
            exit 0
        fi

        if download_lychee_archive; then
            exit 0
        fi

        if command -v cargo-binstall >/dev/null 2>&1; then
            BINSTALL=(cargo-binstall)
        elif command -v cargo >/dev/null 2>&1 && cargo binstall -V >/dev/null 2>&1; then
            BINSTALL=(cargo binstall)
        else
            echo "[Markdown check] Installing cargo-binstall..." >&2
            CARGO_BINSTALL_INSTALLER_URL="${MARKDOWN_LINK_CHECK_CARGO_BINSTALL_INSTALLER_URL:-https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh}"
            curl -L --proto '=https' --tlsv1.2 -sSf \
                "$CARGO_BINSTALL_INSTALLER_URL" \
                | CARGO_HOME="$CARGO_HOME_DIR" BASH_XTRACEFD=7 bash 7>/dev/null >&2
            BINSTALL=("$CARGO_BIN/cargo-binstall")
        fi

        echo "[Markdown check] Installing lychee@$LYCHEE_VERSION..." >&2
        BINSTALL_ARGS=(-y --install-path "$CARGO_BIN")
        if [ -n "${MARKDOWN_LINK_CHECK_LYCHEE_PKG_URL:-}" ]; then
            BINSTALL_ARGS+=(--pkg-url "$MARKDOWN_LINK_CHECK_LYCHEE_PKG_URL")
        fi
        BINSTALL_ARGS+=("lychee@$LYCHEE_VERSION")
        "${BINSTALL[@]}" "${BINSTALL_ARGS[@]}" >&2
        if [ ! -x "$CARGO_BIN/lychee" ]; then
            echo "[Markdown check] lychee binary was not installed." >&2
            exit 1
        fi
        mv -f "$CARGO_BIN/lychee" "$LYCHEE_BIN"
    ); then
        printf '%s\n' "$LYCHEE_BIN"
        return 0
    fi
    exit 1
}

SCAN_SET_FILE=$(mktemp)
LYCHEE_OUTPUT_FILE=$(mktemp)
FILTERED_OUTPUT_FILE=$(mktemp)
CROSS_LINK_OUTPUT_FILE=$(mktemp)
GITCODE_LINK_OUTPUT_FILE=$(mktemp)
trap 'rm -f "$SCAN_SET_FILE" "$LYCHEE_OUTPUT_FILE" "$FILTERED_OUTPUT_FILE" "$CROSS_LINK_OUTPUT_FILE" "$GITCODE_LINK_OUTPUT_FILE"' EXIT

PY_ARGS=("$REPO_ROOT" "$SCAN_SET_FILE")
if [ "${#INPUT_FILES[@]}" -gt 0 ]; then
    PY_ARGS+=("${INPUT_FILES[@]}")
fi

FULL_SCAN="$FULL_SCAN" python3 - "${PY_ARGS[@]}" <<'PY'
import os
import posixpath
import re
import subprocess
import sys
import ast
from pathlib import Path, PurePosixPath
from urllib.parse import unquote, urlparse

repo_root = Path(sys.argv[1])
output_file = Path(sys.argv[2])
input_files = sys.argv[3:]

markdown_exts = (".md", ".markdown", ".mdx")
excluded_prefixes = ()
config_file = repo_root / "markdown-link-check.toml"
full_scan = os.environ.get("FULL_SCAN") == "1"
full_reason = ""


ci_mod_filelist_exists = (repo_root / "pr_filelist_mod.txt").is_file()


def config_exclude_path_patterns():
    try:
        text = config_file.read_text(encoding="utf-8")
    except OSError:
        return []

    match = re.search(r"(?ms)^exclude_path\s*=\s*\[(.*?)\]", text)
    if not match:
        return []

    patterns = []
    for raw_match in re.finditer(r'"((?:\\.|[^"\\])*)"', match.group(1)):
        pattern = raw_match.group(1).replace(r"\\", "\\").replace(r"\"", '"')
        try:
            patterns.append(re.compile(pattern))
        except re.error:
            continue
    return patterns


exclude_path_patterns = config_exclude_path_patterns()


def to_rel(path):
    p = Path(path)
    if p.is_absolute():
        try:
            return p.resolve().relative_to(repo_root.resolve()).as_posix()
        except ValueError:
            return None
    return PurePosixPath(path).as_posix()


def decode_git_path(path):
    if not path or len(path) < 2 or not path.startswith('"') or not path.endswith('"'):
        return path

    try:
        decoded = ast.literal_eval(path)
    except (SyntaxError, ValueError):
        return path

    if not isinstance(decoded, str):
        return path

    try:
        return decoded.encode("latin-1").decode("utf-8")
    except UnicodeError:
        return decoded


def is_markdown_path(path):
    if path is None:
        return False
    return (
        path.lower().endswith(markdown_exts)
        and not path.startswith(excluded_prefixes)
        and not any(pattern.search(path) for pattern in exclude_path_patterns)
    )


def existing_markdown_files():
    try:
        result = subprocess.run(
            ["git", "ls-files", "-z", "*.md", "*.markdown", "*.mdx"],
            cwd=repo_root,
            check=False,
            stdout=subprocess.PIPE,
            stderr=subprocess.DEVNULL,
        )
    except OSError:
        result = None

    if result is not None and result.returncode == 0:
        return sorted(
            path.decode("utf-8", errors="ignore")
            for path in result.stdout.split(b"\0")
            if path and is_markdown_path(path.decode("utf-8", errors="ignore"))
        )

    files = []
    for path in repo_root.rglob("*"):
        if ".git" in path.parts:
            continue
        if not path.is_file() or not path.name.lower().endswith(markdown_exts):
            continue
        rel_path = path.relative_to(repo_root).as_posix()
        if is_markdown_path(rel_path):
            files.append(rel_path)
    return sorted(files)


def markdown_reverse_targets(paths):
    targets = set()
    for path in paths:
        targets.add(path)
        name = PurePosixPath(path).name.lower()
        if name in ("readme.md", "readme_en.md", "index.md"):
            targets.add(PurePosixPath(path).parent.as_posix())
    return targets


def parse_name_status_diff(output):
    changed = []
    reverse_targets = set()
    fields = output.split(b"\0")
    index = 0
    while index < len(fields):
        if not fields[index]:
            index += 1
            continue
        status = fields[index].decode("utf-8", errors="ignore")
        index += 1

        if status.startswith(("R", "C")):
            raw_paths = fields[index:index + 2]
            index += 2
        else:
            raw_paths = fields[index:index + 1]
            index += 1

        if status.startswith("R"):
            if len(raw_paths) != 2:
                continue
            old_path = decode_git_path(raw_paths[0].decode("utf-8", errors="ignore")) if raw_paths[0] else None
            new_path = decode_git_path(raw_paths[1].decode("utf-8", errors="ignore")) if raw_paths[1] else None
            if is_markdown_path(old_path):
                reverse_targets.update(markdown_reverse_targets([old_path]))
            if is_markdown_path(new_path):
                changed.append(new_path)
        elif status.startswith("D"):
            paths = [
                decode_git_path(raw_path.decode("utf-8", errors="ignore"))
                for raw_path in raw_paths
                if raw_path
            ]
            markdown_paths = [p for p in paths if is_markdown_path(p)]
            reverse_targets.update(markdown_reverse_targets(markdown_paths))
        else:
            paths = [
                decode_git_path(raw_path.decode("utf-8", errors="ignore"))
                for raw_path in raw_paths
                if raw_path
            ]
            markdown_paths = [p for p in paths if is_markdown_path(p)]
            changed.extend(markdown_paths)
    return changed, reverse_targets


def ci_markdown_changes():
    filelist = repo_root / "pr_filelist_mod.txt"
    try:
        text = filelist.read_text(encoding="utf-8", errors="ignore")
    except OSError:
        return [], set()

    changed = []
    reverse_targets = set()
    for line in text.splitlines():
        fields = line.split("\t")
        if not fields:
            continue
        status = fields[0]
        paths = [decode_git_path(path) for path in fields[1:]]

        if status.startswith("R"):
            if len(paths) != 2:
                continue
            old_path, new_path = paths
            if is_markdown_path(old_path):
                reverse_targets.update(markdown_reverse_targets([old_path]))
            if is_markdown_path(new_path):
                changed.append(new_path)
        elif status.startswith("D"):
            markdown_paths = [p for p in paths if is_markdown_path(p)]
            reverse_targets.update(markdown_reverse_targets(markdown_paths))
        else:
            markdown_paths = [p for p in paths if is_markdown_path(p)]
            changed.extend(markdown_paths)
    return changed, reverse_targets


def should_skip_chunk(input_paths, changed):
    if not changed:
        return False
    input_set = {path for path in input_paths if path}
    if not input_set:
        return False
    return changed[0] not in input_set


def staged_markdown_changes():
    try:
        result = subprocess.run(
            ["git", "diff", "--cached", "--name-status", "-z", "--diff-filter=ADMR"],
            cwd=repo_root,
            check=False,
            stdout=subprocess.PIPE,
            stderr=subprocess.DEVNULL,
        )
    except OSError:
        return [], set()

    return parse_name_status_diff(result.stdout)


def resolve_base_commit():
    base_candidates = []
    configured_base = os.environ.get("MARKDOWN_LINK_CHECK_BASE_REF")
    if configured_base:
        base_candidates.append(configured_base)

    for env_name in (
        "PR_TARGET_BRANCH",
        "CHANGE_TARGET",
        "CI_MERGE_REQUEST_TARGET_BRANCH_NAME",
        "GIT_TARGET_BRANCH",
        "TARGET_BRANCH",
        "BASE_REF",
    ):
        branch = os.environ.get(env_name)
        if branch:
            base_candidates.extend([f"origin/{branch}", branch])

    base_candidates.extend(["origin/master", "origin/main", "master", "main"])

    for base_ref in dict.fromkeys(base_candidates):
        try:
            verify = subprocess.run(
                ["git", "rev-parse", "--verify", f"{base_ref}^{{commit}}"],
                cwd=repo_root,
                check=False,
                stdout=subprocess.PIPE,
                stderr=subprocess.DEVNULL,
            )
            if verify.returncode != 0:
                continue
            merge_base = subprocess.run(
                ["git", "merge-base", base_ref, "HEAD"],
                cwd=repo_root,
                check=False,
                stdout=subprocess.PIPE,
                stderr=subprocess.DEVNULL,
            )
            if merge_base.returncode == 0:
                commit = merge_base.stdout.decode("utf-8", errors="ignore").strip()
                if commit:
                    return commit
        except OSError:
            continue
    return None


def base_markdown_changes():
    base_commit = resolve_base_commit()
    if not base_commit:
        return [], set()

    try:
        result = subprocess.run(
            ["git", "diff", "--name-status", "-z", "--diff-filter=ADMR", base_commit, "HEAD"],
            cwd=repo_root,
            check=False,
            stdout=subprocess.PIPE,
            stderr=subprocess.DEVNULL,
        )
    except OSError:
        return [], set()

    return parse_name_status_diff(result.stdout)


def markdown_inline_link_targets(text):
    index = 0
    while index < len(text):
        marker = text.find("](", index)
        if marker == -1:
            break

        start = marker + 2
        cursor = start
        depth = 0
        escaped = False
        while cursor < len(text):
            char = text[cursor]
            if escaped:
                escaped = False
            elif char == "\\":
                escaped = True
            elif char == "(":
                depth += 1
            elif char == ")":
                if depth == 0:
                    raw_target = text[start:cursor].strip()
                    if raw_target.startswith("<") and ">" in raw_target:
                        yield raw_target[1:raw_target.find(">")]
                    elif raw_target:
                        yield raw_target.split()[0]
                    break
                depth -= 1
            cursor += 1
        index = cursor + 1 if cursor < len(text) else marker + 2


def extract_link_targets(text):
    # Inline Markdown links/images: [text](target) or ![alt](target)
    yield from markdown_inline_link_targets(text)

    # Reference definitions: [label]: target
    for match in re.finditer(r"(?m)^\s*\[[^\]]+\]:\s*(\S+)", text):
        yield match.group(1)

    # Raw HTML links used by some generated docs.
    for match in re.finditer(r"""(?i)\bhref\s*=\s*["']([^"']+)["']""", text):
        yield match.group(1)


def normalize_target(source_file, target):
    target = target.strip().strip("<>").strip("\"'")
    if not target or target.startswith("#"):
        return None
    if re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*:", target):
        return None

    target = target.split("#", 1)[0].split("?", 1)[0]
    if not target:
        return None

    target = unquote(target)
    if target.startswith("/"):
        normalized = posixpath.normpath(target.lstrip("/"))
    else:
        normalized = posixpath.normpath(posixpath.join(posixpath.dirname(source_file), target))
    return normalized


def normalize_branch_name(branch):
    if branch.startswith("refs/heads/"):
        return branch[len("refs/heads/"):]
    if branch.startswith("origin/"):
        return branch[len("origin/"):]
    return branch


def resolve_target_branch():
    for env_name in (
        "PR_TARGET_BRANCH",
        "CHANGE_TARGET",
        "CI_MERGE_REQUEST_TARGET_BRANCH_NAME",
        "GIT_TARGET_BRANCH",
        "TARGET_BRANCH",
        "BASE_REF",
    ):
        branch = os.environ.get(env_name)
        if branch:
            return normalize_branch_name(branch)
    return None


def split_gitcode_branch_path(parts):
    target_branch = resolve_target_branch()
    if target_branch:
        branch_parts = target_branch.split("/")
        if parts[:len(branch_parts)] == branch_parts and len(parts) > len(branch_parts):
            return target_branch, parts[len(branch_parts):]
        if len(branch_parts) > 1 and parts[0] == branch_parts[0] and len(parts) > len(branch_parts):
            return "/".join(parts[:len(branch_parts)]), parts[len(branch_parts):]

    return parts[0], parts[1:]


def normalize_gitcode_self_target(target):
    parsed = urlparse(target.strip().strip("<>").strip("\"'"))
    if parsed.scheme not in ("http", "https") or parsed.netloc != "gitcode.com":
        return None

    parts = [unquote(part) for part in parsed.path.split("/") if part]
    if len(parts) < 5:
        return None
    if parts[0:2] != ["cann", "asc-devkit"] or parts[2] not in ("blob", "tree"):
        return None

    _, path_parts = split_gitcode_branch_path(parts[3:])
    normalized = posixpath.normpath("/".join(path_parts))
    if normalized.startswith("../") or normalized == ".":
        return None
    return normalized


if full_scan:
    scan_set = existing_markdown_files()
else:
    input_paths = [to_rel(decode_git_path(path)) for path in input_files]
    changed = list(input_paths)
    changed = [path for path in changed if is_markdown_path(path)]

    ci_changes = []
    ci_reverse_targets = set()
    reverse_targets = set()
    if ci_mod_filelist_exists:
        ci_changes, ci_reverse_targets = ci_markdown_changes()
        changed = list(dict.fromkeys(ci_changes))
        reverse_targets = set(ci_reverse_targets)

    staged_changes, staged_reverse_targets = staged_markdown_changes()
    if not ci_mod_filelist_exists and staged_changes and should_skip_chunk(input_paths, staged_changes):
        scan_set = []
    else:
        reverse_targets.update(staged_reverse_targets)
        if not changed:
            changed = staged_changes

        if not ci_mod_filelist_exists:
            base_changes, base_reverse_targets = base_markdown_changes()
            changed.extend(path for path in base_changes if path not in changed)
            reverse_targets.update(base_reverse_targets)

        changed_targets = {path for path in changed if path}
        changed_targets.update(reverse_targets)
        changed_existing = {
            path for path in changed_targets
            if (repo_root / path).is_file()
        }

        all_markdown = existing_markdown_files()
        scan_set = set(changed_existing)
        for source in all_markdown:
            source_path = repo_root / source
            try:
                text = source_path.read_text(encoding="utf-8")
            except UnicodeDecodeError:
                text = source_path.read_text(encoding="utf-8", errors="ignore")
            except OSError:
                continue

            for target in extract_link_targets(text):
                normalized = normalize_target(source, target)
                if normalized is None:
                    normalized = normalize_gitcode_self_target(target)
                if normalized in changed_targets:
                    scan_set.add(source)
                    break

    scan_set = sorted(scan_set)

with output_file.open("w", encoding="utf-8") as handle:
    if full_scan:
        full_reason = full_reason or "requested"
        handle.write(f"# full scan: {full_reason}\n")
    for item in scan_set:
        handle.write(item + "\n")
PY

SCAN_FILES=()
FULL_REASON=""
while IFS= read -r item; do
    case "$item" in
        "# full scan:"*)
            FULL_REASON="${item#"# full scan: "}"
            ;;
        "")
            ;;
        *)
            SCAN_FILES+=("$item")
            ;;
    esac
done < "$SCAN_SET_FILE"

if [ "${#SCAN_FILES[@]}" -eq 0 ]; then
    echo "[Markdown check] PASS: no Markdown files to check. duration=$(markdown_check_duration)"
    exit 0
fi

if [ "$MARKDOWN_LINK_CHECK_DRY_RUN" = "1" ]; then
    printf '%s\n' "${SCAN_FILES[@]}"
    echo "[Markdown check] DRY-RUN: ${#SCAN_FILES[@]} file(s) selected. duration=$(markdown_check_duration)" >&2
    exit 0
fi

set +e
python3 - "$REPO_ROOT" "$SCAN_SET_FILE" "$CROSS_LINK_OUTPUT_FILE" "$(markdown_check_duration)" <<'PY'
import posixpath
import re
import sys
from pathlib import Path
from urllib.parse import unquote

repo_root = Path(sys.argv[1])
scan_set_file = Path(sys.argv[2])
output_file = Path(sys.argv[3])
duration = sys.argv[4]
repo_url = "https://gitcode.com/cann/asc-devkit"

reference_pattern = re.compile(r"(?m)^(\s*\[[^\]]+\]:\s*)(\S+)")
html_href_pattern = re.compile(r"""(?i)(\bhref\s*=\s*["'])([^"']+)(["'])""")
fence_pattern = re.compile(r"^\s*(`{3,}|~{3,})")


def markdown_inline_link_targets(text):
    index = 0
    while index < len(text):
        marker = text.find("](", index)
        if marker == -1:
            break

        start = marker + 2
        cursor = start
        depth = 0
        escaped = False
        while cursor < len(text):
            char = text[cursor]
            if escaped:
                escaped = False
            elif char == "\\":
                escaped = True
            elif char == "(":
                depth += 1
            elif char == ")":
                if depth == 0:
                    raw_target = text[start:cursor].strip()
                    if raw_target.startswith("<") and ">" in raw_target:
                        yield raw_target[1:raw_target.find(">")]
                    elif raw_target:
                        yield raw_target.split()[0]
                    break
                depth -= 1
            cursor += 1
        index = cursor + 1 if cursor < len(text) else marker + 2


def source_area(path):
    if path.startswith("docs/zh/api/"):
        return "api"
    if path.startswith("docs/zh/guide/"):
        return "guide"
    if path.startswith("examples/"):
        return "examples"
    return None


def split_target(target):
    target = target.strip().strip("<>").strip("\"'")
    if not target or target.startswith("#"):
        return None
    if re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*:", target):
        return None

    suffix_index = len(target)
    for sep in ("#", "?"):
        index = target.find(sep)
        if index != -1:
            suffix_index = min(suffix_index, index)
    path_part = target[:suffix_index]
    suffix = target[suffix_index:]
    if not path_part:
        return None
    return path_part, suffix


def normalize_target(source_file, target):
    parts = split_target(target)
    if parts is None:
        return None

    path_part, suffix = parts
    decoded = unquote(path_part)
    if decoded.startswith("/"):
        normalized = posixpath.normpath(decoded.lstrip("/"))
    else:
        normalized = posixpath.normpath(posixpath.join(posixpath.dirname(source_file), decoded))
    return normalized, suffix


def gitcode_url(target_path, suffix):
    view = "tree" if (repo_root / target_path).is_dir() else "blob"
    return f"{repo_url}/{view}/master/{target_path}{suffix}"


def file_url(target_path, suffix):
    readme_path = (repo_root / target_path / "README.md")
    if readme_path.is_file():
        return f"{target_path.rstrip('/')}/README.md{suffix}"
    return f"{target_path}{suffix}"


def iter_links(line):
    yield from markdown_inline_link_targets(line)
    for match in reference_pattern.finditer(line):
        yield match.group(2)
    for match in html_href_pattern.finditer(line):
        yield match.group(2)


def strip_inline_code(line):
    stripped = []
    index = 0
    in_code = False
    while index < len(line):
        char = line[index]
        if char != "`":
            stripped.append(" " if in_code else char)
            index += 1
            continue

        end = index + 1
        while end < len(line) and line[end] == "`":
            end += 1
        stripped.extend(" " * (end - index))
        in_code = not in_code
        index = end
    return "".join(stripped)


scan_files = []
for raw_line in scan_set_file.read_text(encoding="utf-8").splitlines():
    if not raw_line or raw_line.startswith("# full scan:"):
        continue
    scan_files.append(raw_line)

errors = []
for source in scan_files:
    source_kind = source_area(source)
    if source_kind is None:
        continue

    source_path = repo_root / source
    try:
        lines = source_path.read_text(encoding="utf-8").splitlines()
    except UnicodeDecodeError:
        lines = source_path.read_text(encoding="utf-8", errors="ignore").splitlines()
    except OSError:
        continue

    in_fenced_code = False
    for line_number, line in enumerate(lines, 1):
        if fence_pattern.match(line):
            in_fenced_code = not in_fenced_code
            continue
        if in_fenced_code:
            continue

        for target in iter_links(strip_inline_code(line)):
            normalized = normalize_target(source, target)
            if normalized is None:
                continue

            target_path, suffix = normalized
            if (repo_root / target_path).is_dir():
                if source_kind == "examples":
                    if "#" in suffix:
                        errors.append(
                            f"{source}:{line_number}: examples directory link with anchor must target a file: "
                            f"{target} -> {file_url(target_path, suffix)}"
                        )
                else:
                    errors.append(
                        f"{source}:{line_number}: docs Markdown link must target a file, "
                        f"not a directory: {target} -> {file_url(target_path, suffix)}"
                    )
                continue

            target_kind = source_area(target_path)
            if target_kind is None or target_kind == source_kind:
                continue

            if source_kind == "examples" and target_kind in ("api", "guide"):
                continue

            errors.append(
                f"{source}:{line_number}: docs/examples cross-area link must use HTTPS: "
                f"{target} -> {gitcode_url(target_path, suffix)}"
            )

with output_file.open("w", encoding="utf-8") as handle:
    if errors:
        handle.write(
            f"[Markdown check] FAILED: {len(errors)} Markdown link policy item(s), "
            f"{len(scan_files)} file(s) checked, duration={duration}.\n\nFailed:\n"
        )
        for error in errors:
            handle.write(error + "\n")

sys.exit(1 if errors else 0)
PY
CROSS_LINK_RC=$?
set -e

set +e
python3 - "$REPO_ROOT" "$SCAN_SET_FILE" "$GITCODE_LINK_OUTPUT_FILE" "$(markdown_check_duration)" <<'PY'
import os
import posixpath
import re
import subprocess
import sys
from pathlib import Path
from urllib.parse import unquote, urlparse

repo_root = Path(sys.argv[1])
scan_set_file = Path(sys.argv[2])
output_file = Path(sys.argv[3])
duration = sys.argv[4]

reference_pattern = re.compile(r"(?m)^(\s*\[[^\]]+\]:\s*)(\S+)")
html_href_pattern = re.compile(r"""(?i)(\bhref\s*=\s*["'])([^"']+)(["'])""")
fence_pattern = re.compile(r"^\s*(`{3,}|~{3,})")


def markdown_inline_link_targets(text):
    index = 0
    while index < len(text):
        marker = text.find("](", index)
        if marker == -1:
            break

        start = marker + 2
        cursor = start
        depth = 0
        escaped = False
        while cursor < len(text):
            char = text[cursor]
            if escaped:
                escaped = False
            elif char == "\\":
                escaped = True
            elif char == "(":
                depth += 1
            elif char == ")":
                if depth == 0:
                    raw_target = text[start:cursor].strip()
                    if raw_target.startswith("<") and ">" in raw_target:
                        yield raw_target[1:raw_target.find(">")]
                    elif raw_target:
                        yield raw_target.split()[0]
                    break
                depth -= 1
            cursor += 1
        index = cursor + 1 if cursor < len(text) else marker + 2


def iter_links(line):
    yield from markdown_inline_link_targets(line)
    for match in reference_pattern.finditer(line):
        yield match.group(2)
    for match in html_href_pattern.finditer(line):
        yield match.group(2)


def strip_inline_code(line):
    stripped = []
    index = 0
    in_code = False
    while index < len(line):
        char = line[index]
        if char != "`":
            stripped.append(" " if in_code else char)
            index += 1
            continue

        end = index + 1
        while end < len(line) and line[end] == "`":
            end += 1
        stripped.extend(" " * (end - index))
        in_code = not in_code
        index = end
    return "".join(stripped)


def git_ref_for_branch(branch):
    candidates = [
        f"origin/{branch}",
        f"refs/remotes/origin/{branch}",
        branch,
        f"refs/heads/{branch}",
        f"refs/tags/{branch}",
    ]
    for candidate in candidates:
        result = subprocess.run(
            ["git", "rev-parse", "--verify", "--quiet", candidate],
            cwd=repo_root,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            check=False,
        )
        if result.returncode == 0:
            return candidate
    return None


def git_object_type(ref, path):
    result = subprocess.run(
        ["git", "cat-file", "-t", f"{ref}:{path}"],
        cwd=repo_root,
        text=True,
        stdout=subprocess.PIPE,
        stderr=subprocess.DEVNULL,
        check=False,
    )
    if result.returncode != 0:
        return None
    return result.stdout.strip()


def worktree_object_type(path):
    target = repo_root / path
    if target.is_file():
        return "blob"
    if target.is_dir():
        return "tree"
    return None


def normalize_branch_name(branch):
    if branch.startswith("refs/heads/"):
        return branch[len("refs/heads/"):]
    if branch.startswith("origin/"):
        return branch[len("origin/"):]
    return branch


def resolve_target_branch():
    for env_name in (
        "PR_TARGET_BRANCH",
        "CHANGE_TARGET",
        "CI_MERGE_REQUEST_TARGET_BRANCH_NAME",
        "GIT_TARGET_BRANCH",
        "TARGET_BRANCH",
        "BASE_REF",
    ):
        branch = os.environ.get(env_name)
        if branch:
            return normalize_branch_name(branch)
    return None


def resolve_base_commit():
    base_candidates = []
    configured_base = os.environ.get("MARKDOWN_LINK_CHECK_BASE_REF")
    if configured_base:
        base_candidates.append(configured_base)

    for env_name in (
        "PR_TARGET_BRANCH",
        "CHANGE_TARGET",
        "CI_MERGE_REQUEST_TARGET_BRANCH_NAME",
        "GIT_TARGET_BRANCH",
        "TARGET_BRANCH",
        "BASE_REF",
    ):
        branch = os.environ.get(env_name)
        if branch:
            base_candidates.extend([f"origin/{branch}", branch])

    base_candidates.extend(["origin/master", "origin/main", "master", "main"])

    for base_ref in dict.fromkeys(base_candidates):
        try:
            verify = subprocess.run(
                ["git", "rev-parse", "--verify", f"{base_ref}^{{commit}}"],
                cwd=repo_root,
                check=False,
                stdout=subprocess.PIPE,
                stderr=subprocess.DEVNULL,
            )
            if verify.returncode != 0:
                continue
            merge_base = subprocess.run(
                ["git", "merge-base", base_ref, "HEAD"],
                cwd=repo_root,
                check=False,
                stdout=subprocess.PIPE,
                stderr=subprocess.DEVNULL,
            )
            if merge_base.returncode == 0:
                commit = merge_base.stdout.decode("utf-8", errors="ignore").strip()
                if commit:
                    return commit
        except OSError:
            continue
    return None


def parse_added_lines(diff_text):
    added_lines = {}
    current_file = None
    hunk_pattern = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@")

    for line in diff_text.splitlines():
        if line.startswith("+++ b/"):
            current_file = line[len("+++ b/"):]
            added_lines.setdefault(current_file, set())
            continue
        if current_file is None:
            continue

        match = hunk_pattern.match(line)
        if not match:
            continue
        start = int(match.group(1))
        count = int(match.group(2) or "1")
        if start == 0 or count == 0:
            continue
        added_lines[current_file].update(range(start, start + count))

    return added_lines


def merge_added_lines(target, source):
    for path, lines in source.items():
        target.setdefault(path, set()).update(lines)


def diff_added_lines(args):
    try:
        result = subprocess.run(
            args,
            cwd=repo_root,
            text=True,
            stdout=subprocess.PIPE,
            stderr=subprocess.DEVNULL,
            check=False,
        )
    except OSError:
        return {}
    if result.returncode not in (0, 1):
        return {}
    return parse_added_lines(result.stdout)


def gitcode_added_line_map(scan_files, full_scan):
    if full_scan:
        return "all"

    added_lines = {source: set() for source in scan_files}
    base_commit = resolve_base_commit()
    if base_commit:
        merge_added_lines(
            added_lines,
            diff_added_lines(["git", "diff", "--unified=0", "--no-ext-diff", base_commit, "HEAD", "--", *scan_files]),
        )

    merge_added_lines(
        added_lines,
        diff_added_lines(["git", "diff", "--cached", "--unified=0", "--no-ext-diff", "--", *scan_files]),
    )
    merge_added_lines(
        added_lines,
        diff_added_lines(["git", "diff", "--unified=0", "--no-ext-diff", "--", *scan_files]),
    )

    if not base_commit and not any(added_lines.values()):
        return None
    return added_lines


def split_gitcode_branch_path(parts, target_branch):
    if target_branch:
        branch_parts = target_branch.split("/")
        if parts[:len(branch_parts)] == branch_parts and len(parts) > len(branch_parts):
            return target_branch, parts[len(branch_parts):]
        if len(branch_parts) > 1 and parts[0] == branch_parts[0] and len(parts) > len(branch_parts):
            return "/".join(parts[:len(branch_parts)]), parts[len(branch_parts):]

    for branch_len in range(len(parts) - 1, 0, -1):
        branch = "/".join(parts[:branch_len])
        if branch not in ref_cache:
            ref_cache[branch] = git_ref_for_branch(branch)
        if ref_cache[branch] is not None:
            return branch, parts[branch_len:]

    return parts[0], parts[1:]


def asc_devkit_gitcode_target(target):
    parsed = urlparse(target.strip().strip("<>").strip("\"'"))
    if parsed.scheme not in ("http", "https") or parsed.netloc != "gitcode.com":
        return None

    parts = [unquote(part) for part in parsed.path.split("/") if part]
    if len(parts) < 5:
        return None
    if parts[0:2] != ["cann", "asc-devkit"]:
        return None
    if parts[2] not in ("blob", "tree"):
        return None

    branch, path_parts = split_gitcode_branch_path(parts[3:], target_branch)
    path = posixpath.normpath("/".join(path_parts))
    if path.startswith("../") or path == ".":
        return None
    return branch, path


scan_files = []
full_scan = False
for raw_line in scan_set_file.read_text(encoding="utf-8").splitlines():
    if raw_line.startswith("# full scan:"):
        full_scan = True
        continue
    if not raw_line:
        continue
    scan_files.append(raw_line)

errors = []
ref_cache = {}
object_cache = {}
target_branch = resolve_target_branch()
added_line_map = gitcode_added_line_map(scan_files, full_scan)
for source in scan_files:
    source_path = repo_root / source
    try:
        lines = source_path.read_text(encoding="utf-8").splitlines()
    except UnicodeDecodeError:
        lines = source_path.read_text(encoding="utf-8", errors="ignore").splitlines()
    except OSError:
        continue

    in_fenced_code = False
    for line_number, line in enumerate(lines, 1):
        if fence_pattern.match(line):
            in_fenced_code = not in_fenced_code
            continue
        if in_fenced_code:
            continue

        for target in iter_links(strip_inline_code(line)):
            parsed = asc_devkit_gitcode_target(target)
            if parsed is None:
                continue

            branch, target_path = parsed
            if source.startswith("examples/") and target_path.startswith("docs/"):
                parsed_url = urlparse(target.strip().strip("<>").strip("\"'"))
                suffix = f"?{parsed_url.query}" if parsed_url.query else ""
                if parsed_url.fragment:
                    suffix += f"#{parsed_url.fragment}"
                relative_target = posixpath.relpath(target_path, posixpath.dirname(source))
                errors.append(
                    f"{source}:{line_number}: examples link to same-repository docs must use a relative path: "
                    f"{target} -> {relative_target}{suffix}"
                )
                continue

            is_added_line = (
                added_line_map == "all"
                or (added_line_map is not None and line_number in added_line_map.get(source, set()))
            )
            if target_branch is not None and branch != target_branch and is_added_line:
                errors.append(
                    f"{source}:{line_number}: GitCode link branch must match PR target branch: "
                    f"{target} uses {branch}, target branch is {target_branch}"
                )
                continue
            if target_branch is not None and branch == target_branch:
                ref = "working tree"
                cache_key = (ref, target_path)
                if cache_key not in object_cache:
                    object_cache[cache_key] = worktree_object_type(target_path)
            else:
                if branch not in ref_cache:
                    ref_cache[branch] = git_ref_for_branch(branch)
                ref = ref_cache[branch]
                if ref is None:
                    continue

                cache_key = (ref, target_path)
                if cache_key not in object_cache:
                    object_cache[cache_key] = git_object_type(ref, target_path)
            if object_cache[cache_key] is None:
                errors.append(
                    f"{source}:{line_number}: GitCode link target does not exist in {ref}: "
                    f"{target} -> {target_path}"
                )

with output_file.open("w", encoding="utf-8") as handle:
    if errors:
        handle.write(
            f"[Markdown check] FAILED: {len(errors)} GitCode link target item(s), "
            f"{len(scan_files)} file(s) checked, duration={duration}.\n\nFailed:\n"
        )
        for error in errors:
            handle.write(error + "\n")

sys.exit(1 if errors else 0)
PY
GITCODE_LINK_RC=$?
set -e

LYCHEE_BIN=$(find_lychee)
LYCHEE_ARGS=(--no-progress --config "$CONFIG_FILE")
if [ "$MARKDOWN_LINK_CHECK_ONLINE" != "1" ]; then
    LYCHEE_ARGS+=(--offline)
fi
set +e
"$LYCHEE_BIN" "${LYCHEE_ARGS[@]}" "${SCAN_FILES[@]}" >"$LYCHEE_OUTPUT_FILE" 2>&1
LYCHEE_RC=$?
set -e

LYCHEE_DURATION=$(markdown_check_duration)
python3 - "$LYCHEE_OUTPUT_FILE" "$FILTERED_OUTPUT_FILE" "$LYCHEE_RC" "${#SCAN_FILES[@]}" "$LYCHEE_DURATION" <<'PY'
import re
import sys
from pathlib import Path

output_path = Path(sys.argv[1])
filtered_path = Path(sys.argv[2])
return_code = int(sys.argv[3])
file_count = int(sys.argv[4])
duration = sys.argv[5]

lines = output_path.read_text(encoding="utf-8", errors="replace").splitlines()
error_events = []
warning_events = []
current_source = None


def classify(line):
    if re.search(r"\[(WARN|WARNING)\]", line, re.IGNORECASE):
        return "warning"
    if re.search(r"\[(ERROR|FAIL|FAILED)\]", line, re.IGNORECASE):
        return "error"

    status_match = re.match(r"^\[(\d{3})\]\s+.+\s\|", line)
    if status_match and int(status_match.group(1)) >= 400:
        return "error"
    return None


def format_event(line, source):
    location_match = re.search(r"\(at (\d+):(\d+)\)", line)
    if source and location_match:
        clean_line = re.sub(r"\s+\(at \d+:\d+\)", "", line)
        return f"{source}:{location_match.group(1)}:{location_match.group(2)}: {clean_line}", True
    if source:
        return f"{source}: {line}", True
    return line, False


for line in lines:
    stripped = line.strip()
    if not stripped:
        continue

    source_match = re.match(r"^\[([^\]]+)\]:$", stripped)
    if source_match and not re.match(r"^\[\d{3}\]", stripped):
        current_source = source_match.group(1)
        continue

    event_type = classify(stripped)
    if event_type == "error":
        error_events.append(format_event(stripped, current_source))
    elif event_type == "warning":
        warning_events.append(format_event(stripped, current_source))


def unique_events(events):
    seen = set()
    result = []
    for text, has_source in events:
        if text in seen:
            continue
        result.append((text, has_source))
        seen.add(text)
    return result


def prefer_sourced(events):
    events = unique_events(events)
    sourced = [event for event in events if event[1]]
    return [text for text, _ in (sourced if sourced else events)]


error_lines = prefer_sourced(error_events)
warning_lines = prefer_sourced(warning_events)

with filtered_path.open("w", encoding="utf-8") as handle:
    if return_code != 0:
        handle.write(
            f"[Markdown check] FAILED: {len(error_lines)} failed item(s), "
            f"{len(warning_lines)} warning item(s), {file_count} file(s) checked, "
            f"duration={duration}.\n"
        )
        if error_lines:
            handle.write("\nFailed:\n")
            for line in error_lines:
                handle.write(f"{line}\n")
        else:
            handle.write("\nFailed:\n")
            for line in lines:
                if line.strip():
                    handle.write(f"{line.strip()}\n")
    elif warning_lines:
        handle.write(
            f"[Markdown check] WARNING: {len(warning_lines)} warning item(s), "
            f"{file_count} file(s) checked, duration={duration}.\n\nWarnings:\n"
        )

    if warning_lines:
        if return_code != 0:
            handle.write("\nWarnings:\n")
        for line in warning_lines:
            handle.write(f"{line}\n")
PY

FINAL_RC=0
if [ "$CROSS_LINK_RC" -ne 0 ]; then
    cat "$CROSS_LINK_OUTPUT_FILE"
    FINAL_RC=1
fi
if [ "$GITCODE_LINK_RC" -ne 0 ]; then
    if [ "$FINAL_RC" -ne 0 ]; then
        printf '\n'
    fi
    cat "$GITCODE_LINK_OUTPUT_FILE"
    FINAL_RC=1
fi
if [ -s "$FILTERED_OUTPUT_FILE" ]; then
    if [ "$FINAL_RC" -ne 0 ]; then
        printf '\n'
    fi
    cat "$FILTERED_OUTPUT_FILE"
    if [ "$LYCHEE_RC" -ne 0 ]; then
        FINAL_RC=1
    fi
elif [ "$LYCHEE_RC" -eq 0 ]; then
    if [ "$FINAL_RC" -eq 0 ]; then
        echo "[Markdown check] PASS: ${#SCAN_FILES[@]} file(s) checked. duration=$(markdown_check_duration)"
    fi
else
    FINAL_RC=1
fi

exit "$FINAL_RC"