package handler

import (
	"context"
	"encoding/json"
	"net/http"
	"net/url"
	"os"
	"path/filepath"
	"sort"
	"strings"
	"time"

	"codeberg.org/VARASYS/ZDDC/zddc/internal/config"
	"codeberg.org/VARASYS/ZDDC/zddc/internal/listing"
	"codeberg.org/VARASYS/ZDDC/zddc/internal/policy"
	"codeberg.org/VARASYS/ZDDC/zddc/internal/zddc"
)

// IsReviewingPath classifies a URL as a reviewing-aggregator path and
// extracts (project, tracking, sidePath). The aggregator is a virtual
// view at:
//
//	<project>/reviewing/                          → depth 0: pending submittals
//	<project>/reviewing/<tracking>/               → depth 1: received/ + staged/
//	<project>/reviewing/<tracking>/<side>/[...]   → depth ≥ 2: real folder
//	                                                  contents (received or
//	                                                  staged), proxied from
//	                                                  the canonical archive
//	                                                  or staging path so the
//	                                                  user can preview files
//	                                                  in the browse pane
//	                                                  without leaving the
//	                                                  reviewing view.
//
// sidePath at depth 1 is "" (no side selected yet). At depth ≥ 2 it's
// "received[/rest...]" or "staged[/rest...]" — the slash-separated
// remainder after the tracking segment.
//
// Match on "reviewing" is case-insensitive.
func IsReviewingPath(urlPath string) (project, tracking, sidePath string, ok bool) {
	parts := strings.Split(strings.Trim(urlPath, "/"), "/")
	if len(parts) < 2 || !strings.EqualFold(parts[1], "reviewing") {
		return "", "", "", false
	}
	switch len(parts) {
	case 2:
		return parts[0], "", "", true
	case 3:
		return parts[0], parts[2], "", true
	default:
		// parts[3] is the side; remainder joins back as the sub-path
		// within the real folder.
		side := strings.ToLower(parts[3])
		if side != "received" && side != "staged" {
			return "", "", "", false
		}
		rest := strings.Join(parts[4:], "/")
		if rest == "" {
			return parts[0], parts[2], side, true
		}
		return parts[0], parts[2], side + "/" + rest, true
	}
}

// pendingSubmittal is one row of the aggregator's view: a submittal in
// archive/<party>/received/ that doesn't yet have a matching entry in
// archive/<party>/issued/, optionally paired with an in-progress
// response folder under staging/.
type pendingSubmittal struct {
	tracking     string    // canonical tracking number, e.g. "123456-ST-SUB-0026"
	party        string    // party folder name, e.g. "Acme"
	receivedURL  string    // /<project>/archive/<party>/received/<folder>/
	stagedURL    string    // /<project>/staging/<folder>/  or "" if no draft yet
	lastModified time.Time // newer of the two folders' mtimes
}

// computePending walks the project's archive/ and staging/ subtrees to
// build the virtual reviewing-aggregator view.
//
// Algorithm:
//
//  1. Index staging/<folder>/ by tracking number.
//  2. For each party under archive/<party>/:
//     a. Index archive/<party>/issued/ by tracking number.
//     b. For each archive/<party>/received/<folder>:
//     - skip folders that don't parse as transmittal folders.
//     - skip if tracking already in issued (response complete).
//     - emit a pendingSubmittal pointing at the canonical received
//     URL and (if found) the matching staging URL.
//
// ACL: per-party. The caller's email + decider are consulted on the
// archive/<party>/received/ subtree before reading its contents — a
// party the caller can't see at upstream is omitted entirely (no info
// leak via tracking-number listing).
//
// Missing intermediate folders (archive/, party/issued/, staging/) are
// not errors; they just produce empty intermediate sets. This matches
// the lazy-instantiation pattern of the canonical project folders.
func computePending(ctx context.Context, decider policy.Decider,
	fsRoot, project, email string) ([]pendingSubmittal, error) {

	projectAbs := filepath.Join(fsRoot, project)

	// Resolve the canonical folder names to whatever case is present
	// on disk (deployments may use Archive/ Received/ Issued/ Staging/
	// PascalCase). Empty string means no case variant exists — treated
	// as missing (empty contribution to the join).
	archiveOnDisk, _ := zddc.ResolveCanonical(projectAbs, "archive")
	stagingOnDisk, _ := zddc.ResolveCanonical(projectAbs, "staging")

	// Index staging by tracking → folder name.
	stagedByTracking := map[string]string{}
	var stagingAbs string
	if stagingOnDisk != "" {
		stagingAbs = filepath.Join(projectAbs, stagingOnDisk)
		if entries, err := os.ReadDir(stagingAbs); err == nil {
			for _, e := range entries {
				if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
					continue
				}
				if _, tracking, _, _, ok := zddc.ParseTransmittalFolder(e.Name()); ok {
					stagedByTracking[tracking] = e.Name()
				}
			}
		}
	}

	if archiveOnDisk == "" {
		return nil, nil
	}
	archiveAbs := filepath.Join(projectAbs, archiveOnDisk)
	parties, err := os.ReadDir(archiveAbs)
	if err != nil {
		if os.IsNotExist(err) {
			return nil, nil
		}
		return nil, err
	}

	var result []pendingSubmittal
	for _, p := range parties {
		if !p.IsDir() || strings.HasPrefix(p.Name(), ".") {
			continue
		}
		party := p.Name()
		partyAbs := filepath.Join(archiveAbs, party)
		// Per-party canonical folder resolution (Received/ vs received/).
		receivedSeg, _ := zddc.ResolveCanonical(partyAbs, "received")
		issuedSeg, _ := zddc.ResolveCanonical(partyAbs, "issued")
		if receivedSeg == "" {
			continue // party with no received/ at all → nothing to review
		}
		receivedAbs := filepath.Join(partyAbs, receivedSeg)

		// ACL: skip parties whose received/ subtree the caller can't read.
		// Filtering at the party level is cheaper than per-entry and matches
		// fs.ListDirectory's omit-denied-subdirs convention.
		chain, err := zddc.EffectivePolicy(fsRoot, receivedAbs)
		if err != nil {
			continue
		}
		// URL prefix preserves the on-disk casing so links resolve
		// directly against the canonicalisation done by the URL
		// dispatcher (no additional case-fold round-trip needed).
		receivedURLPrefix := "/" + project + "/" + archiveOnDisk + "/" + party + "/" + receivedSeg + "/"
		if allowed, _ := policy.AllowFromChain(ctx, decider, chain, email, receivedURLPrefix); !allowed {
			continue
		}

		// Index this party's issued/ trackings (no ACL filter — issued/
		// is WORM-readable to anyone with party access by design, and
		// we just need the set membership for matching).
		issuedTrackings := map[string]bool{}
		if issuedSeg != "" {
			if entries, err := os.ReadDir(filepath.Join(partyAbs, issuedSeg)); err == nil {
				for _, e := range entries {
					if !e.IsDir() {
						continue
					}
					if _, tracking, _, _, ok := zddc.ParseTransmittalFolder(e.Name()); ok {
						issuedTrackings[tracking] = true
					}
				}
			}
		}

		receivedEntries, err := os.ReadDir(receivedAbs)
		if err != nil {
			continue
		}
		for _, e := range receivedEntries {
			if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
				continue
			}
			_, tracking, _, _, ok := zddc.ParseTransmittalFolder(e.Name())
			if !ok {
				continue
			}
			if issuedTrackings[tracking] {
				continue // response complete; not pending
			}

			info, err := e.Info()
			if err != nil {
				continue
			}
			modTime := info.ModTime()

			sub := pendingSubmittal{
				tracking:     tracking,
				party:        party,
				receivedURL:  receivedURLPrefix + url.PathEscape(e.Name()) + "/",
				lastModified: modTime,
			}
			if stagedFolder, hasDraft := stagedByTracking[tracking]; hasDraft {
				sub.stagedURL = "/" + project + "/" + stagingOnDisk + "/" + url.PathEscape(stagedFolder) + "/"
				if stagedInfo, err := os.Stat(filepath.Join(stagingAbs, stagedFolder)); err == nil {
					if stagedInfo.ModTime().After(modTime) {
						sub.lastModified = stagedInfo.ModTime()
					}
				}
			}
			result = append(result, sub)
		}
	}

	sort.Slice(result, func(i, j int) bool {
		return result[i].tracking < result[j].tracking
	})
	return result, nil
}

// ServeReviewing emits the aggregator JSON listing for any depth under
// <project>/reviewing/. The HTML branch is handled separately by the
// apps subsystem (mdedit served at the URL); only requests that accept
// JSON reach here.
//
// Depths:
//
//	0 (tracking="")          → list pending submittals as virtual
//	                           <tracking>/ folders.
//	1 (tracking, side="")    → list received/ + staged/ virtual folders.
//	≥2 (tracking, sidePath)  → proxy the listing of the real folder
//	                           under archive/<party>/received/<folder>/...
//	                           or staging/<folder>/... so the user can
//	                           preview files without leaving the
//	                           reviewing view. Folder entries keep
//	                           virtual reviewing/ URLs (navigation
//	                           stays in the aggregator). File entries
//	                           use canonical URLs so byte fetches
//	                           resolve directly against the real path.
func ServeReviewing(cfg config.Config, w http.ResponseWriter, r *http.Request,
	project, tracking, sidePath string) {

	pending, err := computePending(r.Context(), DeciderFromContext(r),
		cfg.Root, project, EmailFromContext(r))
	if err != nil {
		http.Error(w, "Internal Server Error", http.StatusInternalServerError)
		return
	}

	var entries []listing.FileInfo
	switch {
	case tracking == "":
		// Depth 0: list pending submittals as virtual <tracking>/ folders.
		urlPrefix := "/" + project + "/reviewing/"
		for _, s := range pending {
			entries = append(entries, listing.FileInfo{
				Name:    s.tracking + "/",
				URL:     urlPrefix + url.PathEscape(s.tracking) + "/",
				ModTime: s.lastModified,
				IsDir:   true,
				Virtual: true,
			})
		}
	default:
		// Depth ≥1: find the pending entry for this tracking number.
		var match *pendingSubmittal
		for i := range pending {
			if pending[i].tracking == tracking {
				match = &pending[i]
				break
			}
		}
		if match == nil {
			http.Error(w, "Not Found", http.StatusNotFound)
			return
		}
		if sidePath == "" {
			// Depth 1: emit received/ + staged/ virtual folder pointers.
			// URLs stay under reviewing/ so navigation into them remains
			// in the aggregator (handled by the depth ≥2 branch).
			urlPrefix := "/" + project + "/reviewing/" + url.PathEscape(tracking) + "/"
			entries = append(entries, listing.FileInfo{
				Name:    "received/",
				URL:     urlPrefix + "received/",
				ModTime: match.lastModified,
				IsDir:   true,
				Virtual: true,
			})
			if match.stagedURL != "" {
				entries = append(entries, listing.FileInfo{
					Name:    "staged/",
					URL:     urlPrefix + "staged/",
					ModTime: match.lastModified,
					IsDir:   true,
					Virtual: true,
				})
			}
		} else {
			// Depth ≥2: proxy the real folder's listing. sidePath is
			// "received[/rest]" or "staged[/rest]" — split off the
			// leading side, append remainder to the canonical base.
			side := sidePath
			rest := ""
			if i := strings.IndexByte(sidePath, '/'); i >= 0 {
				side, rest = sidePath[:i], sidePath[i+1:]
			}
			var realURL string
			switch side {
			case "received":
				realURL = match.receivedURL
			case "staged":
				if match.stagedURL == "" {
					http.Error(w, "Not Found", http.StatusNotFound)
					return
				}
				realURL = match.stagedURL
			default:
				http.Error(w, "Not Found", http.StatusNotFound)
				return
			}
			if rest != "" {
				realURL = strings.TrimSuffix(realURL, "/") + "/" + rest + "/"
			}
			// Translate the real URL back to a filesystem path so we
			// can list it. The URL still encodes percent-escapes;
			// PathUnescape them before joining.
			realRel := strings.TrimPrefix(realURL, "/")
			realRel = strings.TrimSuffix(realRel, "/")
			realRelDecoded, decodeErr := url.PathUnescape(realRel)
			if decodeErr != nil {
				http.Error(w, "Internal Server Error", http.StatusInternalServerError)
				return
			}
			realAbs := filepath.Join(cfg.Root, filepath.FromSlash(realRelDecoded))
			if !strings.HasPrefix(realAbs, cfg.Root+string(filepath.Separator)) {
				http.Error(w, "Not Found", http.StatusNotFound)
				return
			}
			// ACL on the underlying real path; do not proxy what the
			// caller can't read directly.
			chain, err := zddc.EffectivePolicy(cfg.Root, realAbs)
			if err == nil {
				if allowed, _ := policy.AllowFromChain(r.Context(),
					DeciderFromContext(r), chain,
					EmailFromContext(r), realURL); !allowed {
					http.Error(w, "Forbidden", http.StatusForbidden)
					return
				}
			}
			diskEntries, err := os.ReadDir(realAbs)
			if err != nil {
				if os.IsNotExist(err) {
					http.Error(w, "Not Found", http.StatusNotFound)
					return
				}
				http.Error(w, "Internal Server Error", http.StatusInternalServerError)
				return
			}
			// Build the virtual URL prefix (for folder entries) and
			// the canonical URL prefix (for file entries).
			virtualPrefix := "/" + project + "/reviewing/" +
				url.PathEscape(tracking) + "/" + side + "/"
			if rest != "" {
				virtualPrefix += rest + "/"
			}
			canonicalPrefix := realURL // already ends with "/"
			for _, e := range diskEntries {
				name := e.Name()
				if strings.HasPrefix(name, ".") {
					continue
				}
				info, err := e.Info()
				if err != nil {
					continue
				}
				fi := listing.FileInfo{
					Name:    name,
					ModTime: info.ModTime(),
				}
				if e.IsDir() {
					fi.Name += "/"
					fi.IsDir = true
					fi.URL = virtualPrefix + url.PathEscape(name) + "/"
					fi.Virtual = true
				} else {
					fi.Size = info.Size()
					// File URL points at the canonical real path so
					// fetches (preview, download) hit the right bytes
					// directly — no proxying through the aggregator.
					fi.URL = canonicalPrefix + url.PathEscape(name)
				}
				entries = append(entries, fi)
			}
			sort.Slice(entries, func(i, j int) bool {
				// Folders first, then files; both alphabetical.
				if entries[i].IsDir != entries[j].IsDir {
					return entries[i].IsDir
				}
				return entries[i].Name < entries[j].Name
			})
		}
	}

	if entries == nil {
		entries = []listing.FileInfo{}
	}
	w.Header().Set("Content-Type", "application/json; charset=utf-8")
	w.Header().Set("Cache-Control", "no-store") // virtual; recompute every time
	w.Header().Set("X-ZDDC-Source", "reviewing-aggregator")
	_ = json.NewEncoder(w).Encode(entries)
}
