package models

import (
	"bytes"
	"compress/gzip"
	"encoding/hex"
	"fmt"
	"io"
	"maps"
	"slices"
	"strings"
	"time"

	"tangled.org/core/api/tangled"
	"tangled.org/core/appview/pages/markup/sanitizer"
	"tangled.org/core/patchutil"

	"github.com/bluesky-social/indigo/atproto/syntax"
)

type PullState int

const (
	PullClosed PullState = iota
	PullOpen
	PullMerged
	PullAbandoned
)

func (p PullState) String() string {
	switch p {
	case PullOpen:
		return "open"
	case PullMerged:
		return "merged"
	case PullClosed:
		return "closed"
	case PullAbandoned:
		return "abandoned"
	default:
		return "closed"
	}
}

func (p PullState) IsOpen() bool {
	return p == PullOpen
}
func (p PullState) IsMerged() bool {
	return p == PullMerged
}
func (p PullState) IsClosed() bool {
	return p == PullClosed
}
func (p PullState) IsAbandoned() bool {
	return p == PullAbandoned
}

type Pull struct {
	ID       int64 // appview-local PR id. Used for quick referencing
	OwnerDid syntax.DID
	Rkey     syntax.RecordKey
	Cid      syntax.CID
	RepoDid  syntax.DID
	PullId   int64

	Title        string
	Body         string
	TargetBranch string
	SourceRepo   syntax.DID
	SourceBranch *string
	Versions     []PullVersion
	Created      time.Time

	State PullState

	// optionally, populate this when querying for reverse mappings
	Labels LabelState
	Repo   *Repo
}

func (p *Pull) AtUri() syntax.ATURI {
	return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.OwnerDid, tangled.RepoPullNSID, p.Rkey))
}

func (p *Pull) AsRecord() tangled.RepoPull {
	sourceRepo := p.SourceRepo.String()
	var sourceBranch string
	if p.SourceBranch != nil {
		sourceBranch = *p.SourceBranch
	}
	versions := make([]*tangled.RepoPull_Version, len(p.Versions))
	for i, v := range p.Versions {
		var base *string
		if v.Base != "" {
			base = &v.Base
		}
		versions[i] = &tangled.RepoPull_Version{
			Base:      base,
			Head:      v.Head,
			CreatedAt: v.Created.Format(time.RFC3339),
		}
	}
	return tangled.RepoPull{
		Title: p.Title,
		Body:  &p.Body,
		Target: &tangled.RepoPull_Target{
			Repo:   p.RepoDid.String(),
			Branch: p.TargetBranch,
		},
		Source: &tangled.RepoPull_Source{
			Repo:   &sourceRepo,
			Branch: sourceBranch,
		},
		Versions:  versions,
		CreatedAt: p.Created.Format(time.RFC3339),
	}
}

func (p *Pull) Validate() error {
	if len(p.Versions) == 0 {
		return fmt.Errorf("pull must have at least one version")
	}

	if p.Title == "" {
		return fmt.Errorf("pull title is empty (required for non-format-patch pulls)")
	}
	if st := strings.TrimSpace(sanitizer.SanitizeDescription(p.Title)); st == "" {
		return fmt.Errorf("title is empty after HTML sanitization")
	}

	for i, version := range p.Versions {
		if err := version.Validate(); err != nil {
			return fmt.Errorf("versions[%d]: %w", i, err)
		}
	}
	return nil
}

func (v *PullVersion) Validate() error {
	if v.Base != "" && !IsHash(v.Base) {
		return fmt.Errorf("invalid base commit id: %q", v.Base)
	}
	if !IsHash(v.Head) {
		return fmt.Errorf("invalid head commit id: %q", v.Head)
	}
	return nil
}

func PullFromRecord(did syntax.DID, rkey syntax.RecordKey, cid syntax.CID, record tangled.RepoPull, versions []PullVersion) (*Pull, error) {
	created, err := time.Parse(time.RFC3339, record.CreatedAt)
	if err != nil {
		return nil, fmt.Errorf("invalid createdAt: %w", err)
	}

	body := ""
	if record.Body != nil {
		body = *record.Body
	}

	// var mentions []syntax.DID
	// for _, m := range record.Mentions {
	// 	if did, err := syntax.ParseDID(m); err == nil {
	// 		mentions = append(mentions, did)
	// 	}
	// }

	var targetRepoDid syntax.DID
	var targetBranch string
	if record.Target != nil {
		did, err := syntax.ParseDID(record.Target.Repo)
		if err != nil {
			return nil, fmt.Errorf("invalid target.repo did: %w", err)
		}
		targetRepoDid = did
		targetBranch = record.Target.Branch
	}

	var sourceRepo syntax.DID
	var sourceBranch *string
	if record.Source != nil {
		if record.Source.Repo != nil {
			did, err := syntax.ParseDID(*record.Source.Repo)
			if err != nil {
				return nil, fmt.Errorf("invalid source.repo did: %w", err)
			}
			sourceRepo = did
		}
		if record.Source.Branch != "" {
			sourceBranch = new(string)
			*sourceBranch = record.Source.Branch
		}
	}

	return &Pull{
		ID:       -1, // uninitialized
		OwnerDid: did,
		Rkey:     rkey,
		Cid:      cid,
		RepoDid:  targetRepoDid,
		PullId:   0, // uninitialized

		Title:        record.Title,
		Body:         body,
		TargetBranch: targetBranch,
		SourceRepo:   sourceRepo,
		SourceBranch: sourceBranch,
		Versions:     versions,
		Created:      created,
		State:        PullOpen, // default to open
	}, nil
}

func PullVersionFromRecord(idx int, record *tangled.RepoPull_Version) (PullVersion, error) {
	created, err := time.Parse(time.RFC3339, record.CreatedAt)
	if err != nil {
		return PullVersion{}, fmt.Errorf("invalid createdAt: %w", err)
	}

	var base string
	if record.Base != nil {
		base = *record.Base
	}
	return PullVersion{
		ID:      idx,
		Base:    base,
		Head:    record.Head,
		Created: created,
	}, nil
}

func PullVersionFromLegacy(idx int, record *tangled.RepoPull_Round, reader io.Reader) (PullVersion, error) {
	created, err := time.Parse(time.RFC3339, record.CreatedAt)
	if err != nil {
		return PullVersion{}, fmt.Errorf("invalid createdAt: %w", err)
	}

	patch, err := extractGzip(reader)
	if err != nil {
		return PullVersion{}, fmt.Errorf("failed to extract gzip: %w", err)
	}
	if !patchutil.IsFormatPatch(patch) {
		return PullVersion{}, fmt.Errorf("only format-patch patch is supported")
	}

	var sourceRev string
	patches, err := patchutil.ExtractPatches(patch)
	if err != nil {
		return PullVersion{}, fmt.Errorf("failed to extract patches: %w", err)
	}
	for _, part := range patches {
		sourceRev = part.SHA
	}
	if sourceRev == "" {
		return PullVersion{}, fmt.Errorf("source rev is missing")
	}
	return PullVersion{
		ID:      idx,
		Base:    "",
		Head:    sourceRev,
		Created: created,
	}, nil
}

type PullSource struct {
	Branch  string
	RepoDid *syntax.DID

	// optionally populate this for reverse mappings
	Repo *Repo
}

func (s *PullSource) AsRecord() *tangled.RepoPull_Source {
	if s == nil {
		return nil
	}
	var repo *string
	if s.RepoDid != nil {
		r := s.RepoDid.String()
		repo = &r
	}
	return &tangled.RepoPull_Source{
		Branch: s.Branch,
		Repo:   repo,
	}
}

type PullVersion struct {
	ID      int
	Head    string // head commit ID
	Base    string // base commit ID (for combined interdiff)
	Created time.Time

	// reverse mappings
	Comments []Comment
}

func (p *Pull) TotalComments() int {
	total := 0
	for _, s := range p.Versions {
		total += len(s.Comments)
	}
	return total
}

func (p *Pull) GetVersion(id int) (PullVersion, bool) {
	for _, version := range p.Versions {
		if version.ID == id {
			return version, true
		}
	}
	return PullVersion{}, false
}

func (p *Pull) LatestVersionNumber() int {
	return len(p.Versions) - 1
}

func (p *Pull) LatestVersion() PullVersion {
	return p.Versions[p.LatestVersionNumber()]
}

func (p *Pull) LatestSha() string {
	return p.LatestVersion().Head
}

func (p *Pull) IsForkBased() bool {
	return p.RepoDid != p.SourceRepo
}

func (p *Pull) Participants() []syntax.DID {
	participants := make(map[syntax.DID]struct{})

	for _, v := range p.Versions {
		for _, sp := range v.Participants() {
			participants[sp] = struct{}{}
		}
	}

	return slices.Collect(maps.Keys(participants))
}

func (s *PullVersion) Participants() []syntax.DID {
	participants := make(map[syntax.DID]struct{})

	for _, c := range s.Comments {
		participants[c.Did] = struct{}{}
	}

	return slices.Collect(maps.Keys(participants))
}

func extractGzip(blob io.Reader) (string, error) {
	var b bytes.Buffer
	r, err := gzip.NewReader(blob)
	if err != nil {
		return "", err
	}
	defer r.Close()

	const maxSize = 15 * 1024 * 1024
	limitedReader := io.LimitReader(r, maxSize)

	_, err = io.Copy(&b, limitedReader)
	if err != nil {
		return "", err
	}

	return b.String(), nil
}

func IsHash(s string) bool {
	switch len(s) {
	case 40: // SHA1
	case 64: // SHA2
	default:
		return false
	}
	_, err := hex.DecodeString(s)
	return err == nil
}
