package main

import (
	"bytes"
	"net/http"
	"os"
	"path/filepath"
	"regexp"
	"strconv"
	"strings"
)

// taskCheckboxRe matches the <input> tag goldmark's GFM extension emits for a
// task list checkbox. Used to enumerate and rewrite checkboxes in rendered HTML.
var taskCheckboxRe = regexp.MustCompile(`<input(?: checked="")? disabled="" type="checkbox">`)

// taskLineRe matches a markdown task list line: leading whitespace, a bullet,
// then a `[ ]` / `[x]` / `[X]` checkbox marker.
var taskLineRe = regexp.MustCompile(`^(\s*[-*+]\s+)\[([ xX])\]`)

// rewriteTaskCheckboxes enables and indexes the task checkboxes in rendered
// HTML so JS can wire them up. Each checkbox gains a data-task-index matching
// its position among task list items in source order; the disabled attribute
// is removed so the user can toggle them.
func rewriteTaskCheckboxes(in []byte) []byte {
	idx := 0
	return taskCheckboxRe.ReplaceAllFunc(in, func(match []byte) []byte {
		checked := bytes.Contains(match, []byte("checked"))
		var out bytes.Buffer
		out.WriteString(`<input type="checkbox" class="task-checkbox" data-task-index="`)
		out.WriteString(strconv.Itoa(idx))
		out.WriteByte('"')
		if checked {
			out.WriteString(` checked=""`)
		}
		out.WriteByte('>')
		idx++
		return out.Bytes()
	})
}

// handleToggle flips the Nth task list checkbox in index.md based on the
// `toggle` query param and `checked` form value. Indices match the order in
// which goldmark emits checkboxes, which is source order excluding fenced
// code blocks.
func (h *handler) handleToggle(w http.ResponseWriter, r *http.Request, fsPath string) {
	n, err := strconv.Atoi(r.URL.Query().Get("toggle"))
	if err != nil || n < 0 {
		http.Error(w, "bad toggle index", http.StatusBadRequest)
		return
	}
	if err := r.ParseForm(); err != nil {
		http.Error(w, "bad request", http.StatusBadRequest)
		return
	}
	checked := r.FormValue("checked") == "true"

	indexPath := filepath.Join(fsPath, "index.md")
	raw, err := os.ReadFile(indexPath)
	if err != nil {
		http.Error(w, "read failed: "+err.Error(), http.StatusInternalServerError)
		return
	}

	updated, ok := flipTaskLine(raw, n, checked)
	if !ok {
		http.Error(w, "task not found", http.StatusNotFound)
		return
	}

	if err := writeFileAtomic(indexPath, updated, 0644); err != nil {
		http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
		return
	}
	w.WriteHeader(http.StatusNoContent)
}

// flipTaskLine returns raw with the Nth task list bullet's `[ ]`/`[x]` marker
// set according to checked. Lines inside fenced code blocks are skipped so
// they do not consume an index. Returns ok=false when there is no Nth task.
func flipTaskLine(raw []byte, n int, checked bool) ([]byte, bool) {
	lines := bytes.Split(raw, []byte("\n"))
	inFence := false
	count := 0
	target := -1
	for i, line := range lines {
		trimmed := bytes.TrimLeft(line, " \t")
		if bytes.HasPrefix(trimmed, []byte("```")) || bytes.HasPrefix(trimmed, []byte("~~~")) {
			inFence = !inFence
			continue
		}
		if inFence {
			continue
		}
		if !taskLineRe.Match(line) {
			continue
		}
		if count == n {
			target = i
			break
		}
		count++
	}
	if target == -1 {
		return nil, false
	}
	replacement := []byte("${1}[ ]")
	if checked {
		replacement = []byte("${1}[x]")
	}
	lines[target] = taskLineRe.ReplaceAll(lines[target], replacement)
	return bytes.Join(lines, []byte("\n")), true
}

// handleCleanTasks rewrites index.md with every completed task line — and its
// continuation lines — removed. Triggered by POST /{path}?cleantasks=1.
func (h *handler) handleCleanTasks(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
	indexPath := filepath.Join(fsPath, "index.md")
	raw, err := os.ReadFile(indexPath)
	if err != nil {
		http.Error(w, "read failed: "+err.Error(), http.StatusInternalServerError)
		return
	}
	updated := stripCompletedTasks(raw)
	if !bytes.Equal(updated, raw) {
		if err := writeFileAtomic(indexPath, updated, 0644); err != nil {
			http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
			return
		}
	}
	http.Redirect(w, r, urlPath, http.StatusSeeOther)
}

// handleAddTask appends a single `- [ ] text` task to the last task list in
// the selected section, or creates a new list at the end of the section if
// none exists. Triggered by POST /{path}?addtask=<section-index> with form
// field `text`.
func (h *handler) handleAddTask(w http.ResponseWriter, r *http.Request, urlPath, fsPath string) {
	sectionIndex, err := strconv.Atoi(r.URL.Query().Get("addtask"))
	if err != nil || sectionIndex < 1 {
		http.Error(w, "bad section index", http.StatusBadRequest)
		return
	}
	if err := r.ParseForm(); err != nil {
		http.Error(w, "bad request", http.StatusBadRequest)
		return
	}
	text := r.FormValue("text")
	if strings.ContainsAny(text, "\r\n") {
		http.Error(w, "text must be single-line", http.StatusBadRequest)
		return
	}
	text = strings.TrimSpace(text)
	if text == "" {
		http.Error(w, "empty text", http.StatusBadRequest)
		return
	}

	indexPath := filepath.Join(fsPath, "index.md")
	raw, err := os.ReadFile(indexPath)
	if err != nil {
		http.Error(w, "read failed: "+err.Error(), http.StatusInternalServerError)
		return
	}
	sections := splitSections(raw)
	if sectionIndex >= len(sections) {
		http.Error(w, "section out of range", http.StatusBadRequest)
		return
	}

	sections[sectionIndex] = appendToLastTaskList(sections[sectionIndex], text)
	updated := joinSections(sections)
	if err := writeFileAtomic(indexPath, updated, 0644); err != nil {
		http.Error(w, "write failed: "+err.Error(), http.StatusInternalServerError)
		return
	}

	target := urlPath
	ids := headingIDs(updated)
	if sectionIndex-1 < len(ids) {
		target = urlPath + "#" + ids[sectionIndex-1]
	}
	http.Redirect(w, r, target, http.StatusSeeOther)
}

// splitLines returns raw split on '\n', dropping the trailing empty element
// produced when raw ends in '\n', and reports whether that newline was there.
// reassemble undoes the split with the matching trailing-newline state.
func splitLines(raw []byte) (lines [][]byte, trailingNewline bool) {
	trailingNewline = len(raw) > 0 && raw[len(raw)-1] == '\n'
	lines = bytes.Split(raw, []byte("\n"))
	if trailingNewline && len(lines) > 0 && len(lines[len(lines)-1]) == 0 {
		lines = lines[:len(lines)-1]
	}
	return lines, trailingNewline
}

func reassemble(lines [][]byte, trailingNewline bool) []byte {
	out := bytes.Join(lines, []byte("\n"))
	if trailingNewline && len(out) > 0 {
		out = append(out, '\n')
	}
	return out
}

// isFence reports whether line opens or closes a fenced code block.
func isFence(line []byte) bool {
	t := bytes.TrimLeft(line, " \t")
	return bytes.HasPrefix(t, []byte("```")) || bytes.HasPrefix(t, []byte("~~~"))
}

// indentWidth counts leading-whitespace columns, tabs and spaces equally.
// Adequate for the user's own wiki text, where mixed tab/space indents are rare.
func indentWidth(line []byte) int {
	n := 0
	for n < len(line) && (line[n] == ' ' || line[n] == '\t') {
		n++
	}
	return n
}

// stripCompletedTasks removes every `[x]`/`[X]` task line and its continuation
// lines (blank, or indented strictly more than the bullet) from raw. Lines
// inside fenced code blocks are ignored, matching flipTaskLine's contract.
func stripCompletedTasks(raw []byte) []byte {
	lines, trailing := splitLines(raw)
	out := make([][]byte, 0, len(lines))
	inFence := false
	for i := 0; i < len(lines); i++ {
		line := lines[i]
		if isFence(line) {
			inFence = !inFence
			out = append(out, line)
			continue
		}
		if !inFence {
			if m := taskLineRe.FindSubmatch(line); m != nil && (m[2][0] == 'x' || m[2][0] == 'X') {
				bulletIndent := indentWidth(line)
				j := i + 1
				for j < len(lines) {
					next := lines[j]
					if len(bytes.TrimSpace(next)) > 0 && indentWidth(next) <= bulletIndent {
						break
					}
					j++
				}
				i = j - 1
				continue
			}
		}
		out = append(out, line)
	}
	return reassemble(out, trailing)
}

// appendToLastTaskList inserts `- [ ] text` after the last task list item in
// sectionBytes. If no task list exists in the section, it appends a new list
// at the end, separated by a blank line. Bullet character and indent are
// inherited from the existing last item when present.
func appendToLastTaskList(sectionBytes []byte, text string) []byte {
	lines, trailing := splitLines(sectionBytes)

	// Forward scan: track fence state and remember the last non-fenced task line.
	lastTask, lastPrefix, lastIndent := -1, "", 0
	inFence := false
	for i, line := range lines {
		if isFence(line) {
			inFence = !inFence
			continue
		}
		if inFence {
			continue
		}
		if m := taskLineRe.FindSubmatch(line); m != nil {
			lastTask = i
			lastPrefix = string(m[1])
			lastIndent = indentWidth(line)
		}
	}

	if lastTask >= 0 {
		// Walk forward over continuation lines (blank or more-indented), then
		// back over trailing blanks so the new task slots in after the last
		// real content line of the item.
		end := lastTask + 1
		for end < len(lines) {
			next := lines[end]
			if len(bytes.TrimSpace(next)) > 0 && indentWidth(next) <= lastIndent {
				break
			}
			end++
		}
		for end > lastTask+1 && len(bytes.TrimSpace(lines[end-1])) == 0 {
			end--
		}
		newLine := []byte(lastPrefix + "[ ] " + text)
		out := append(append(append([][]byte{}, lines[:end]...), newLine), lines[end:]...)
		return reassemble(out, trailing)
	}

	// No task list — append one at section end, blank-line-separated from any
	// preceding content. Trim trailing blanks first to control spacing exactly.
	for len(lines) > 0 && len(bytes.TrimSpace(lines[len(lines)-1])) == 0 {
		lines = lines[:len(lines)-1]
	}
	if len(lines) > 0 {
		lines = append(lines, nil)
	}
	lines = append(lines, []byte("- [ ] "+text))
	return reassemble(lines, trailing)
}
