// Package portstest provides in-memory fakes for the driven ports, for
// use in core/app service tests (and anywhere else a backend-free stand-in
// helps).
package portstest

import (
	"context"
	"fmt"
	"strings"
	"sync"
	"time"

	"github.com/dtoro/oikos/internal/core/domain"
	"github.com/dtoro/oikos/internal/core/ports"
)

// EntityRepo is an in-memory ports.EntityRepository. Command inputs' audit,
// event, and derived-check fields are recorded for assertion.
type EntityRepo struct {
	mu      sync.Mutex
	byID    map[domain.UUID]domain.Entity
	bySlug  map[string]domain.UUID
	order   []domain.UUID
	nextID  int
	Audits     []ports.AuditEntry
	Events     []ports.Event
	Checks     map[domain.UUID][]ports.CheckDef
	Idempotent map[string]ports.IdempotentResponse
	Rederived  []domain.UUID
	Enrolled   map[domain.UUID]time.Time
	ErrStub    error // returned by every command when set
}

// NewEntityRepo builds an empty in-memory entity repository.
func NewEntityRepo() *EntityRepo {
	return &EntityRepo{
		byID:   make(map[domain.UUID]domain.Entity),
		bySlug: make(map[string]domain.UUID),
		Checks:     make(map[domain.UUID][]ports.CheckDef),
		Idempotent: make(map[string]ports.IdempotentResponse),
	}
}

// Get returns the entity by ID.
func (r *EntityRepo) Get(_ context.Context, id domain.UUID) (domain.Entity, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	e, ok := r.byID[id]
	if !ok {
		return domain.Entity{}, domain.ErrNotFound
	}
	return e, nil
}

// BySlug returns the entity by slug.
func (r *EntityRepo) BySlug(_ context.Context, slug string) (domain.Entity, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	id, ok := r.bySlug[slug]
	if !ok {
		return domain.Entity{}, domain.ErrNotFound
	}
	return r.byID[id], nil
}

// List returns entities filtered by type/state, bounded by limit, with the
// next cursor ("" when exhausted).
func (r *EntityRepo) List(_ context.Context, f ports.EntityFilters) ([]domain.Entity, string, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	var out []domain.Entity
	for _, id := range r.order {
		e := r.byID[id]
		if f.Type != "" && e.Type != f.Type {
			continue
		}
		if f.State != "" && e.State != f.State {
			continue
		}
		out = append(out, e)
		if f.Limit > 0 && len(out) >= f.Limit {
			break
		}
	}
	next := ""
	if len(out) == f.Limit && f.Limit > 0 {
		next = "cursor-more"
	}
	return out, next, nil
}

// Search matches name/slug substrings.
func (r *EntityRepo) Search(ctx context.Context, q string, limit int) ([]domain.Entity, error) {
	// Substring over name/slug is enough for service tests.
	all, _, err := r.List(ctx, ports.EntityFilters{Limit: limit})
	if err != nil {
		return nil, err
	}
	var out []domain.Entity
	for _, e := range all {
		if strings.Contains(e.Name, q) || strings.Contains(e.Slug, q) {
			out = append(out, e)
		}
	}
	return out, nil
}

// Create stores a new entity and records its input side-effects.
func (r *EntityRepo) Create(_ context.Context, in ports.EntityCreateInput) (domain.Entity, error) {
	if r.ErrStub != nil {
		return domain.Entity{}, r.ErrStub
	}
	r.mu.Lock()
	defer r.mu.Unlock()
	if in.Entity.ID == "" {
		r.nextID++
		in.Entity.ID = domain.UUID(fmt.Sprintf("fake-entity-%03d", r.nextID))
	}
	if _, dup := r.bySlug[in.Entity.Slug]; dup {
		return domain.Entity{}, domain.ErrAlreadyExists
	}
	if in.Entity.Version == 0 {
		in.Entity.Version = 1
	}
	if in.EnrolledAt != nil {
		if r.Enrolled == nil {
			r.Enrolled = make(map[domain.UUID]time.Time)
		}
		r.Enrolled[in.Entity.ID] = *in.EnrolledAt
	}
	r.store(in.Entity)
	r.Audits = append(r.Audits, in.Audit...)
	if in.Event != nil {
		r.Events = append(r.Events, *in.Event)
	}
	if in.Idempotency != nil {
		var body []byte
		if in.Idempotency.RenderBody != nil {
			body = in.Idempotency.RenderBody(in.Entity)
		}
		r.Idempotent[in.Idempotency.Actor+"\x00"+in.Idempotency.Key] = ports.IdempotentResponse{
			RequestHash: in.Idempotency.RequestHash, ResponseCode: 201, ResponseBody: body,
		}
	}
	return in.Entity, nil
}

// Update replaces a stored entity and records its input side-effects.
// The optimistic-version check mirrors the postgres WHERE version clause.
func (r *EntityRepo) Update(_ context.Context, in ports.EntityUpdateInput) (domain.Entity, error) {
	if r.ErrStub != nil {
		return domain.Entity{}, r.ErrStub
	}
	r.mu.Lock()
	defer r.mu.Unlock()
	current, ok := r.byID[in.Entity.ID]
	if !ok {
		return domain.Entity{}, domain.ErrNotFound
	}
	if in.ExpectedVersion > 0 && current.Version != in.ExpectedVersion {
		return domain.Entity{}, domain.ErrConflict
	}
	after := in.Entity
	after.Version = current.Version + 1
	if in.RederiveChecks {
		r.Rederived = append(r.Rederived, after.ID)
	}
	delete(r.bySlug, current.Slug)
	r.store(after)
	r.Audits = append(r.Audits, in.Audit...)
	if in.Event != nil {
		r.Events = append(r.Events, *in.Event)
	}
	if in.Idempotency != nil {
		var body []byte
		if in.Idempotency.RenderBody != nil {
			body = in.Idempotency.RenderBody(in.Entity)
		}
		r.Idempotent[in.Idempotency.Actor+"\x00"+in.Idempotency.Key] = ports.IdempotentResponse{
			RequestHash: in.Idempotency.RequestHash, ResponseCode: 201, ResponseBody: body,
		}
	}
	return in.Entity, nil
}

// SetState applies a lifecycle transition, refusing stale From states.
func (r *EntityRepo) SetState(_ context.Context, in ports.EntityTransitionInput) (domain.Entity, error) {
	if r.ErrStub != nil {
		return domain.Entity{}, r.ErrStub
	}
	r.mu.Lock()
	defer r.mu.Unlock()
	id, ok := r.bySlug[in.Slug]
	if !ok {
		return domain.Entity{}, domain.ErrNotFound
	}
	e := r.byID[id]
	if e.State != in.From {
		return domain.Entity{}, domain.ErrConflict
	}
	e.State = in.To
	r.byID[id] = e
	r.Audits = append(r.Audits, in.Audit...)
	if in.Event != nil {
		r.Events = append(r.Events, *in.Event)
	}
	return e, nil
}

func (r *EntityRepo) store(e domain.Entity) {
	r.byID[e.ID] = e
	r.bySlug[e.Slug] = e.ID
	r.order = append(r.order, e.ID)
}

// GetIdempotent returns the cached response for (actor, key).
func (r *EntityRepo) GetIdempotent(_ context.Context, actor, key string) (ports.IdempotentResponse, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	cached, ok := r.Idempotent[actor+"\x00"+key]
	if !ok {
		return ports.IdempotentResponse{}, domain.ErrNotFound
	}
	return cached, nil
}

// FindBySlug is a test helper bypassing the port interface.
func (r *EntityRepo) FindBySlug(slug string) (domain.Entity, bool) {
	r.mu.Lock()
	defer r.mu.Unlock()
	id, ok := r.bySlug[slug]
	if !ok {
		return domain.Entity{}, false
	}
	return r.byID[id], true
}

// RecordingExecutor records every command it is asked to run and replies
// with canned results (default: empty success).
type RecordingExecutor struct {
	mu      sync.Mutex
	Calls   []ExecCall
	Results []ports.ExecResult // popped in order; last one repeats
}

// ExecCall is one recorded CommandExecutor.Run invocation.
type ExecCall struct {
	Target  ports.Target
	Command string
}

// Run records the call and replies with the next canned result.
func (e *RecordingExecutor) Run(_ context.Context, target ports.Target, command string, _ ports.ExecOpts) ports.ExecResult {
	e.mu.Lock()
	defer e.mu.Unlock()
	e.Calls = append(e.Calls, ExecCall{Target: target, Command: command})
	if len(e.Results) == 0 {
		return ports.ExecResult{}
	}
	res := e.Results[0]
	if len(e.Results) > 1 {
		e.Results = e.Results[1:]
	}
	return res
}

// FakeChecker answers per check kind; unregistered kinds report "unknown".
type FakeChecker struct {
	mu     sync.Mutex
	ByKind map[string]ports.CheckResult
	Calls  []string
}

// Check answers from the kind map, recording the call.
func (c *FakeChecker) Check(_ context.Context, def ports.CheckDef, _ ports.Target) ports.CheckResult {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.Calls = append(c.Calls, def.Kind)
	if res, ok := c.ByKind[def.Kind]; ok {
		return res
	}
	return ports.CheckResult{State: "unknown"}
}

// SpyPublisher records published events.
type SpyPublisher struct {
	mu     sync.Mutex
	Events []ports.Event
	Err    error
}

// Publish records the event.
func (p *SpyPublisher) Publish(_ context.Context, event ports.Event) error {
	p.mu.Lock()
	defer p.mu.Unlock()
	p.Events = append(p.Events, event)
	return p.Err
}

// RelRepo is an in-memory ports.RelationshipRepository.
type RelRepo struct {
	mu    sync.Mutex
	edges []domain.Relationship
	// ErrStubFor maps a target entity ID to an error to return when an
	// edge onto that target is created (for best-effort-path tests).
	ErrStubFor map[domain.UUID]error
}

// NewRelRepo builds an empty in-memory relationship repository.
func NewRelRepo() *RelRepo { return &RelRepo{} }

// Create stores the edge as-is.
func (r *RelRepo) Create(_ context.Context, in ports.RelationshipCreateInput) (domain.Relationship, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	if r.ErrStubFor != nil {
		if err, ok := r.ErrStubFor[in.Relationship.TargetID]; ok {
			return domain.Relationship{}, err
		}
	}
	r.edges = append(r.edges, in.Relationship)
	return in.Relationship, nil
}

// End soft-deletes matching current edges.
func (r *RelRepo) End(_ context.Context, source, target domain.UUID, relType string) error {
	r.mu.Lock()
	defer r.mu.Unlock()
	kept := r.edges[:0]
	for _, e := range r.edges {
		if e.SourceID == source && e.TargetID == target && e.Type == relType {
			continue
		}
		kept = append(kept, e)
	}
	r.edges = kept
	return nil
}

// ListFor returns edges touching the entity in the given direction
// ("outbound", "inbound", or both).
func (r *RelRepo) ListFor(_ context.Context, entityID domain.UUID, direction string) ([]domain.Relationship, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	var out []domain.Relationship
	for _, e := range r.edges {
		switch direction {
		case "outbound":
			if e.SourceID == entityID {
				out = append(out, e)
			}
		case "inbound":
			if e.TargetID == entityID {
				out = append(out, e)
			}
		default:
			if e.SourceID == entityID || e.TargetID == entityID {
				out = append(out, e)
			}
		}
	}
	return out, nil
}

// FakeResolver resolves every slug onto a fixed endpoint; check-shaped
// lookups can be overridden per type.
type FakeResolver struct {
	Addr string
	User string
	Err  error
	// ErrForType maps entity types to a resolve error (check path).
	ErrForType map[string]error
}

// ResolveExecTarget resolves any slug to the fixed endpoint.
func (r *FakeResolver) ResolveExecTarget(_ context.Context, _ string) (ports.Target, error) {
	if r.Err != nil {
		return ports.Target{}, r.Err
	}
	return ports.Target{Host: r.Addr, User: r.User}, nil
}

// ResolveForCheck resolves a check target by entity type.
func (r *FakeResolver) ResolveForCheck(_ context.Context, _ domain.UUID, entityType string) (ports.Target, error) {
	if err, ok := r.ErrForType[entityType]; ok {
		return ports.Target{}, err
	}
	return r.ResolveExecTarget(context.Background(), entityType)
}

// ResolveHost resolves any host slug to the fixed endpoint.
func (r *FakeResolver) ResolveHost(_ context.Context, _, _ string) (string, string, error) {
	if r.Err != nil {
		return "", "", r.Err
	}
	return r.Addr, r.User, nil
}

// IsGuest marks guest types reached via pct/qm.
func (r *FakeResolver) IsGuest(entityType string) bool {
	return entityType == "lxc" || entityType == "vm"
}

// FakeProvisioner records provision requests and replies with canned
// results (default: a successful create echoing the request's VMID).
type FakeProvisioner struct {
	mu      sync.Mutex
	LXCs    []ports.LXCInput
	VMs     []ports.VMInput
	LXCRes  ports.ProvisionResult
	LXCErr  error
	VMErr   error
}

// CreateLXC records the request and replies with the canned result.
func (p *FakeProvisioner) CreateLXC(_ context.Context, in ports.LXCInput) (ports.ProvisionResult, error) {
	p.mu.Lock()
	defer p.mu.Unlock()
	p.LXCs = append(p.LXCs, in)
	return p.LXCRes, p.LXCErr
}

// CreateVM records the request and replies with the canned error.
func (p *FakeProvisioner) CreateVM(_ context.Context, in ports.VMInput) (ports.ProvisionResult, error) {
	p.mu.Lock()
	defer p.mu.Unlock()
	p.VMs = append(p.VMs, in)
	return ports.ProvisionResult{}, p.VMErr
}

// SeedRepo is an in-memory ports.SeedRepository. Files map to canned
// (counts, applied) pairs; every ingest records the file content.
type SeedRepo struct {
	mu        sync.Mutex
	Files     map[string][]byte
	Applied   map[string]bool
	IngestErr map[string]error
	// Order records the files ingested, in order.
	Order       []string
	ExportFiles map[string][]byte
	ExportErr   error
}

// NewSeedRepo builds an empty in-memory seed repository.
func NewSeedRepo() *SeedRepo {
	return &SeedRepo{
		Files:     make(map[string][]byte),
		Applied:   make(map[string]bool),
		IngestErr: make(map[string]error),
	}
}

func (r *SeedRepo) ingest(file string, content []byte) (ports.SeedCounts, bool, error) {
	r.mu.Lock()
	defer r.mu.Unlock()
	if err := r.IngestErr[file]; err != nil {
		return ports.SeedCounts{}, false, err
	}
	r.Files[file] = content
	r.Order = append(r.Order, file)
	return ports.SeedCounts{}, r.Applied[file], nil
}

// IngestOntology records the ontology seed file.
func (r *SeedRepo) IngestOntology(_ context.Context, file string, content []byte) (ports.SeedCounts, bool, error) {
	return r.ingest(file, content)
}

// IngestInventory records the inventory seed file.
func (r *SeedRepo) IngestInventory(_ context.Context, file string, content []byte) (ports.SeedCounts, bool, error) {
	return r.ingest(file, content)
}

// IngestPolicy records the policy seed file.
func (r *SeedRepo) IngestPolicy(_ context.Context, file string, content []byte) (ports.SeedCounts, bool, error) {
	return r.ingest(file, content)
}

// IngestKnowledge records the knowledge seed file.
func (r *SeedRepo) IngestKnowledge(_ context.Context, file string, content []byte) (ports.SeedCounts, bool, error) {
	return r.ingest(file, content)
}

// Export returns the canned export payload.
func (r *SeedRepo) Export(_ context.Context) (map[string][]byte, error) {
	return r.ExportFiles, r.ExportErr
}
