#!/usr/bin/env python3
"""Validate AI-MANIFEST.md files against the ai-manifest/1 format.

Usage:
    ai-manifest-check.py [FILE ...]        # default: AI-MANIFEST.md
    ai-manifest-check.py --json FILE       # also print the parsed record(s)

Spec: https://ai-manifest.ahnlak.com/spec/1

Exit status is 0 if every file is valid and 1 otherwise. With --strict,
warnings also count as failures. Needs only the Python 3.8+ standard library.
"""

import argparse
import json
import re
import sys
from datetime import date

VERSION = "1.0.0"
FORMAT = "ai-manifest/1"
LEVELS = ["none", "consulted", "suggested", "co-written", "directed", "delegated"]
NEEDS_REVIEW = set(LEVELS[2:])
REVIEWS = ["none", "light", "full"]
FAMILIARITY = ["expert", "proficient", "learning"]
HOSTING = ["hosted", "local"]
AREAS = ["concept", "spec", "planning", "design", "code", "debugging",
         "tests", "styling", "docs", "assets", "ops"]

HEADER_FIELDS = ["format", "updated", "tools", "default level", "default review"]
AREA_FIELDS = ["level", "review", "familiarity", "paths"]

H1 = re.compile(r"^ {0,3}#[ \t]+(.+?)[ \t#]*$")
H2 = re.compile(r"^ {0,3}##[ \t]+(.+?)[ \t#]*$")
FENCE = re.compile(r"^ {0,3}(```|~~~)")
RULE = re.compile(r"^ {0,3}([-*_])([ \t]*\1){2,}[ \t]*$")
BULLET = re.compile(r"^ {0,3}[-*+][ \t]+")
FIELD = re.compile(r"^ {0,3}[-*+][ \t]+(?:\*\*)?([^:*]+?)[ \t]*:[ \t]*(?:\*\*)?[ \t]*(.*?)[ \t]*$")
DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")


class Report:
    def __init__(self):
        self.errors = []
        self.warnings = []

    def error(self, line, msg):
        self.errors.append((line, msg))

    def warn(self, line, msg):
        self.warnings.append((line, msg))


def strip_comments(text):
    """Blank out HTML comments outside code blocks, as Markdown hides them.

    Line breaks are kept, so line numbers in messages stay correct.
    Returns (text, unclosed).
    """
    out = []
    in_fence = in_comment = False
    for line in text.splitlines():
        if not in_comment and FENCE.match(line):
            in_fence = not in_fence
            out.append(line)
            continue
        if in_fence:
            out.append(line)
            continue
        kept, rest = "", line
        while rest:
            if in_comment:
                end = rest.find("-->")
                if end < 0:
                    break
                rest, in_comment = rest[end + 3:], False
            else:
                start = rest.find("<!--")
                if start < 0:
                    kept += rest
                    break
                kept += rest[:start]
                rest, in_comment = rest[start + 4:], True
        out.append(kept.rstrip() if kept.strip() else "")
    return "\n".join(out), in_comment


def split_sections(text, rep):
    """Split the document into the header section and the ## area sections."""
    title = None
    sections = []
    current = None
    in_fence = False
    prev_blank = True

    for num, raw in enumerate(text.splitlines(), 1):
        line = raw.rstrip()
        if FENCE.match(line):
            in_fence = not in_fence
        elif not in_fence:
            if title is not None and RULE.match(line):
                if not prev_blank:
                    rep.error(num, "a horizontal rule needs a blank line before it "
                                   "(otherwise Markdown turns the previous line into a heading)")
                break
            m = H1.match(line)
            if m:
                if title is not None:
                    rep.error(num, "only one '#' title is allowed")
                    continue
                title = m.group(1)
                current = {"heading": None, "line": num, "body": []}
                sections.append(current)
                prev_blank = True
                continue
            m = H2.match(line)
            if m:
                if title is None:
                    rep.error(num, "'##' section before the '#' title")
                current = {"heading": m.group(1), "line": num, "body": []}
                sections.append(current)
                prev_blank = True
                continue

        if current is None:
            if line.strip():
                rep.error(num, "content before the '#' title")
        else:
            current["body"].append((num, line))
        prev_blank = not line.strip()

    if in_fence:
        rep.warn(None, "unclosed code block")
    return title, sections


def is_item(line):
    return bool(BULLET.match(line)) and not RULE.match(line)


def split_body(body, rep):
    """Return (field lines, prose) for a section body.

    The field list is the first list in the section. Blank lines are allowed
    before it and between its items, as they are in Markdown.
    """
    n = len(body)
    i = 0
    while i < n and not body[i][1].strip():
        i += 1
    fields = []
    while i < n and is_item(body[i][1]):
        fields.append(body[i])
        i += 1
        j = i
        while j < n and not body[j][1].strip():
            j += 1
        if j > i and j < n and is_item(body[j][1]):
            i = j
    if fields and i < n and body[i][1].strip():
        rep.warn(body[i][0], "add a blank line after the field list, otherwise Markdown "
                             "displays this line as part of the last field")
    prose = "\n".join(text for _, text in body[i:])
    prose = re.sub(r"\n{3,}", "\n\n", prose).strip()
    return fields, prose


def read_fields(lines, allowed, rep):
    fields, extensions = {}, {}
    for num, text in lines:
        m = FIELD.match(text)
        if not m:
            rep.error(num, f"expected '- Key: value', got {text.strip()!r} (a list directly "
                           "after the field list is part of it; put prose between them)")
            continue
        name = m.group(1)
        key = " ".join(name.split()).lower()
        value = m.group(2)
        if key.startswith("x-"):
            extensions[name.strip()] = value
            continue
        if key not in allowed:
            rep.error(num, f"unknown field {name.strip()!r} "
                           f"(allowed here: {', '.join(a.capitalize() for a in allowed)})")
            continue
        if key in fields:
            rep.error(num, f"duplicate field {name.strip()!r}")
            continue
        if not value:
            rep.error(num, f"field {name.strip()!r} is empty")
            continue
        fields[key] = (num, value)
    return fields, extensions


def enum(fields, key, allowed, rep):
    if key not in fields:
        return None
    num, value = fields[key]
    v = value.strip().lower()
    if v not in allowed:
        rep.error(num, f"{key.capitalize()} {value!r} is not one of: {', '.join(allowed)}")
        return None
    return v


def check_review(line, level, review, where, rep, given=False):
    # `given` means a Review was written but invalid; that's already reported.
    if level in NEEDS_REVIEW and review is None and not given:
        rep.error(line, f"{where}: level '{level}' needs a Review")
    if level in ("none", "consulted") and review is not None:
        rep.warn(line, f"{where}: Review has no meaning at level '{level}'")


def parse_tools(fields, rep):
    if "tools" not in fields:
        return []
    num, value = fields["tools"]
    if value.strip().lower() == "none":
        return []
    tools = []
    for item in value.split(","):
        item = item.strip()
        if not item:
            rep.error(num, "empty entry in Tools")
            continue
        m = re.fullmatch(r"(.+?)\s*\(([^()]*)\)", item)
        if m:
            name, hosting = m.group(1).strip(), m.group(2).strip().lower()
            if hosting not in HOSTING:
                rep.error(num, f"tool {name!r}: hosting {m.group(2)!r} is not one of: "
                               f"{', '.join(HOSTING)}")
                hosting = None
        else:
            name, hosting = item, None
            rep.warn(num, f"tool {item!r} doesn't say where it ran, e.g. '{item} (local)'")
        tools.append({"name": name, "hosting": hosting})
    return tools


def parse_paths(fields, rep):
    if "paths" not in fields:
        return []
    num, value = fields["paths"]
    paths = re.findall(r"`([^`]+)`", value)
    leftover = re.sub(r"`[^`]+`", "", value).replace(",", "").strip()
    if leftover or not paths:
        rep.error(num, "Paths must be backtick-quoted globs separated by commas, "
                       "e.g. `src/ui/**`, `*.css`")
    return paths


def parse(text):
    rep = Report()
    text, unclosed = strip_comments(text)
    if unclosed:
        rep.warn(None, "unclosed HTML comment; everything after '<!--' was ignored")
    title, sections = split_sections(text, rep)
    if title is None:
        rep.error(1, "missing '#' title")
        return None, rep

    # Header
    head = sections[0]
    lines, summary = split_body(head["body"], rep)
    hdr, extensions = read_fields(lines, HEADER_FIELDS, rep)

    if "format" not in hdr:
        rep.error(head["line"], f"missing 'Format: {FORMAT}' in the header list "
                                "(is this an AI manifest?)")
        return None, rep
    num, raw = hdr["format"]
    # Accept `ai-manifest/1`, [ai-manifest/1](url) and plain ai-manifest/1.
    spec_url = None
    m = re.fullmatch(r"\[([^\]]+)\]\(\s*<?([^\s>)]+)>?(?:\s+\"[^\"]*\")?\s*\)", raw.strip())
    if m:
        raw, spec_url = m.group(1), m.group(2)
    fmt = raw.strip().strip("`").strip().lower()
    if fmt != FORMAT:
        rep.error(num, f"unsupported format {raw.strip()!r} (this tool understands {FORMAT})")

    updated = None
    if "updated" not in hdr:
        rep.warn(head["line"], "no 'Updated' date in the header list")
    else:
        num, updated = hdr["updated"]
        try:
            if not DATE.match(updated):
                raise ValueError
            date.fromisoformat(updated)
        except ValueError:
            rep.error(num, f"Updated {updated!r} is not a YYYY-MM-DD date")

    tools = parse_tools(hdr, rep)

    default_level = enum(hdr, "default level", LEVELS, rep)
    default_review = enum(hdr, "default review", REVIEWS, rep)
    if "default level" not in hdr:
        rep.error(head["line"], "missing 'Default level' in the header list")
    check_review(hdr.get("default level", (head["line"],))[0],
                 default_level, default_review, "Default", rep, "default review" in hdr)

    if not summary:
        rep.warn(head["line"], "no summary prose after the header list")

    # Areas
    areas = []
    seen = {}
    for sec in sections[1:]:
        num = sec["line"]
        m = re.fullmatch(r"([^:]+?)\s*(?::\s*(.+))?", sec["heading"])
        if not m:
            rep.error(num, f"malformed heading {sec['heading']!r} (use '## Area' or '## Area: label')")
            continue
        area = " ".join(m.group(1).split()).lower()
        label = m.group(2).strip() if m.group(2) else None
        where = f"'{sec['heading']}'"

        if area not in AREAS:
            rep.warn(num, f"{where}: '{m.group(1).strip()}' is not a standard area")
        key = (area, (label or "").lower())
        if key in seen:
            rep.error(num, f"{where}: duplicate section (first at line {seen[key]})")
        seen[key] = num

        lines, notes = split_body(sec["body"], rep)
        fields, area_ext = read_fields(lines, AREA_FIELDS, rep)
        level = enum(fields, "level", LEVELS, rep)
        review = enum(fields, "review", REVIEWS, rep)
        familiarity = enum(fields, "familiarity", FAMILIARITY, rep)
        paths = parse_paths(fields, rep)

        if "level" not in fields:
            rep.error(num, f"{where}: no Level (the field list must come directly after the heading)")
        check_review(num, level, review, where, rep, "review" in fields)
        if area == "code" and not label:
            rep.warn(num, f"{where}: name the language, e.g. '## Code: Rust'")
        if area == "code" and familiarity is None and "familiarity" not in fields:
            rep.warn(num, f"{where}: no Familiarity")

        areas.append({
            "area": area,
            "label": label,
            "line": num,
            "level": level,
            "level_rank": LEVELS.index(level) if level else None,
            "review": review,
            "familiarity": familiarity,
            "paths": paths,
            "notes": notes,
            "extensions": area_ext,
        })

    used_ai = (default_level not in (None, "none")
               or any(a["level"] not in (None, "none") for a in areas))
    if used_ai and "tools" not in hdr:
        rep.warn(head["line"], "AI was involved but there's no 'Tools' field")

    doc = {
        "format": fmt,
        "spec": spec_url,
        "title": title,
        "updated": updated,
        "summary": summary,
        "tools": tools,
        "default": {
            "level": default_level,
            "level_rank": LEVELS.index(default_level) if default_level else None,
            "review": default_review,
        },
        "areas": areas,
        "extensions": extensions,
    }
    return doc, rep


def main():
    ap = argparse.ArgumentParser(description=f"Validate {FORMAT} AI manifest files.")
    ap.add_argument("--version", action="version",
                    version=f"%(prog)s {VERSION} (supports {FORMAT})")
    ap.add_argument("files", nargs="*", default=["AI-MANIFEST.md"],
                    help="files to check (default: AI-MANIFEST.md)")
    ap.add_argument("--json", action="store_true",
                    help="print the parsed records as a JSON list on stdout")
    ap.add_argument("--strict", action="store_true", help="treat warnings as failures")
    ap.add_argument("-q", "--quiet", action="store_true", help="don't print warnings")
    args = ap.parse_args()

    failed = False
    docs = []
    for path in args.files:
        try:
            with open(path, encoding="utf-8-sig") as fh:
                text = fh.read()
        except OSError as e:
            print(f"{path}: error: {e.strerror}", file=sys.stderr)
            failed = True
            continue

        doc, rep = parse(text)

        def loc(line):
            return f"{path}:{line}" if line else path

        for line, msg in sorted(rep.errors, key=lambda e: e[0] or 0):
            print(f"{loc(line)}: error: {msg}", file=sys.stderr)
        if not args.quiet:
            for line, msg in sorted(rep.warnings, key=lambda w: w[0] or 0):
                print(f"{loc(line)}: warning: {msg}", file=sys.stderr)

        bad = bool(rep.errors) or (args.strict and bool(rep.warnings))
        failed = failed or bad
        if not bad and not args.quiet:
            extra = f" ({len(rep.warnings)} warning(s))" if rep.warnings else ""
            print(f"{path}: ok{extra}", file=sys.stderr)
        if doc is not None and not rep.errors:
            doc = {"file": path, **doc}
            docs.append(doc)

    if args.json:
        print(json.dumps(docs, indent=2, ensure_ascii=False))
    return 1 if failed else 0


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